Skip to content

Commit f37a47e

Browse files
authored
Merge pull request #1871 from LazyXuan/fix/query-tools-readonly-annotations
fix(mcp): non-mutating query-only store resolve; annotate the ten read-only tools
2 parents 6cca768 + adb49b3 commit f37a47e

2 files changed

Lines changed: 149 additions & 65 deletions

File tree

‎src/mcp/mcp.c‎

Lines changed: 91 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -754,23 +754,30 @@ typedef struct {
754754
} tool_annotation_def_t;
755755

756756
/* Tool annotations are deliberately explicit. All tools operate on the local
757-
* repository/index domain, so none cross an open-world trust boundary. */
757+
* repository/index domain, so none cross an open-world trust boundary.
758+
*
759+
* The ten pure query tools resolve their store through resolve_store(), whose
760+
* query-only path is strictly non-mutating: a corrupt database is reported and
761+
* left in place, never quarantined or rebuilt — quarantine/rebuild is reserved
762+
* for write-side opens (index_repository, manage_adr writes). That is what
763+
* makes readOnlyHint=true honest for them and lets plan-mode clients expose
764+
* them (the "read-only analysis tools" surface described in #1100). */
758765
static const tool_annotation_def_t TOOL_ANNOTATIONS[] = {
759766
{"index_repository", false, false, true, false},
760-
{"search_graph", false, true, true, false},
761-
{"query_graph", false, true, true, false},
762-
{"trace_path", false, true, true, false},
763-
{"get_code_snippet", false, true, true, false},
767+
{"search_graph", true, false, true, false},
768+
{"query_graph", true, false, true, false},
769+
{"trace_path", true, false, true, false},
770+
{"get_code_snippet", true, false, true, false},
764771
{"get_file_outline", false, true, true, false},
765-
{"get_graph_schema", false, true, true, false},
772+
{"get_graph_schema", true, false, true, false},
766773
{"compare_graphs", true, false, true, false},
767-
{"get_architecture", false, true, true, false},
768-
{"search_code", false, true, true, false},
774+
{"get_architecture", true, false, true, false},
775+
{"search_code", true, false, true, false},
769776
{"list_projects", true, false, true, false},
770777
{"delete_project", false, true, true, false},
771-
{"index_status", false, true, true, false},
772-
{"check_index_coverage", false, true, true, false},
773-
{"detect_changes", false, true, true, false},
778+
{"index_status", true, false, true, false},
779+
{"check_index_coverage", true, false, true, false},
780+
{"detect_changes", true, false, true, false},
774781
{"manage_adr", false, true, false, false},
775782
{"ingest_traces", false, false, false, false},
776783
};
@@ -1624,6 +1631,12 @@ struct cbm_mcp_server {
16241631
bool owns_store; /* true if we opened the store */
16251632
char *current_project; /* which project store is open for (heap) */
16261633
time_t store_last_used; /* last time resolve_store was called for a named project */
1634+
/* Set by a query-only resolve that hit a confirmed-corrupt database and
1635+
* left it in place (no quarantine, no rebuild). Error builders read this
1636+
* so the tool reply names the corruption instead of a misleading
1637+
* "project not found". */
1638+
bool readonly_resolve_hit_corrupt;
1639+
char readonly_corrupt_project[CBM_SZ_1K];
16271640

16281641
/* Session + auto-index state */
16291642
char session_root[CBM_SZ_1K]; /* detected project root path */
@@ -2217,14 +2230,21 @@ typedef enum {
22172230
STORE_RECOVERY_NONE,
22182231
STORE_RECOVERY_BUSY,
22192232
STORE_RECOVERY_TRY_GUARD_UNAVAILABLE,
2233+
/* Query-only resolve: a confirmed-corrupt database was reported and left
2234+
* in place. Not an error state of the recovery machinery — the caller is
2235+
* expected to answer with the corruption, not retry. */
2236+
STORE_RECOVERY_CORRUPT,
22202237
} store_recovery_status_t;
22212238

22222239
static cbm_store_t *resolve_store_internal(cbm_mcp_server_t *srv, const char *project,
22232240
bool mutation_already_held, bool nonblocking_recovery,
2224-
store_recovery_status_t *recovery_status) {
2241+
store_recovery_status_t *recovery_status,
2242+
bool allow_autorecovery) {
22252243
if (recovery_status) {
22262244
*recovery_status = STORE_RECOVERY_NONE;
22272245
}
2246+
srv->readonly_resolve_hit_corrupt = false;
2247+
srv->readonly_corrupt_project[0] = '\0';
22282248
if (!project) {
22292249
return NULL; /* project is required — no implicit fallback */
22302250
}
@@ -2254,6 +2274,38 @@ static cbm_store_t *resolve_store_internal(cbm_mcp_server_t *srv, const char *pr
22542274
project_db_path(project, path, sizeof(path));
22552275
srv->store = path[0] ? cbm_store_open_path_query(path) : NULL;
22562276
if (srv->store) {
2277+
/* Query-only resolve: classify a failed integrity check without any
2278+
* mutation — no lease, no quarantine. A corrupt database is reported
2279+
* and left in place for a write-side open (index_repository) to
2280+
* quarantine and rebuild; that is what keeps the ten read-only
2281+
* tools' readOnlyHint=true honest. A transient failure (lock, IO, or
2282+
* the shallow check's own prepare-error-as-corrupt blind spot) is
2283+
* answered as busy and retried by the next resolve. */
2284+
if (!allow_autorecovery && !cbm_store_check_integrity(srv->store)) {
2285+
cbm_store_close(srv->store);
2286+
srv->store = NULL;
2287+
srv->store = cbm_store_open_path_query(path);
2288+
cbm_integrity_verdict_t verdict = srv->store
2289+
? cbm_store_check_integrity_verdict(srv->store)
2290+
: CBM_INTEGRITY_TRANSIENT;
2291+
if (srv->store) {
2292+
cbm_store_close(srv->store);
2293+
srv->store = NULL;
2294+
}
2295+
if (verdict == CBM_INTEGRITY_CORRUPT) {
2296+
srv->readonly_resolve_hit_corrupt = true;
2297+
snprintf(srv->readonly_corrupt_project, sizeof(srv->readonly_corrupt_project), "%s",
2298+
project);
2299+
cbm_log_warn("store.corrupt_readonly", "project", project, "path", path, "action",
2300+
"left in place for a write-side rebuild");
2301+
if (recovery_status) {
2302+
*recovery_status = STORE_RECOVERY_CORRUPT;
2303+
}
2304+
} else if (recovery_status) {
2305+
*recovery_status = STORE_RECOVERY_BUSY;
2306+
}
2307+
return NULL;
2308+
}
22572309
/* Check DB integrity — back up (never silently delete) a corrupt DB */
22582310
if (!cbm_store_check_integrity(srv->store)) {
22592311
cbm_store_close(srv->store);
@@ -2358,7 +2410,8 @@ static cbm_store_t *resolve_store_internal(cbm_mcp_server_t *srv, const char *pr
23582410
}
23592411

23602412
static cbm_store_t *resolve_store(cbm_mcp_server_t *srv, const char *project) {
2361-
return resolve_store_internal(srv, project, false, false, NULL);
2413+
/* Query-only callers (every read tool): strictly non-mutating resolve. */
2414+
return resolve_store_internal(srv, project, false, false, NULL, false);
23622415
}
23632416

23642417
/* Forward decl — definition lives below alongside list_projects. */
@@ -2489,16 +2542,28 @@ static char *build_no_store_error(const char *project) {
24892542
: build_missing_project_error();
24902543
}
24912544

2545+
/* Same contract as build_no_store_error, but when the query-only resolve
2546+
* confirmed a corrupt database and left it in place, name that instead of a
2547+
* misleading "project not found". */
2548+
static char *build_no_store_error_checked(cbm_mcp_server_t *srv, const char *project) {
2549+
if (srv->readonly_resolve_hit_corrupt && project &&
2550+
strcmp(project, srv->readonly_corrupt_project) == 0) {
2551+
return build_project_list_error(
2552+
"project store is corrupt (left untouched); run index_repository to rebuild it");
2553+
}
2554+
return build_no_store_error(project);
2555+
}
2556+
24922557
/* Bail with the right error when no store is available. */
2493-
#define REQUIRE_STORE(store, project) \
2494-
do { \
2495-
if (!(store)) { \
2496-
char *_err = build_no_store_error(project); \
2497-
char *_res = cbm_mcp_text_result(_err, true); \
2498-
free(_err); \
2499-
free(project); \
2500-
return _res; \
2501-
} \
2558+
#define REQUIRE_STORE(store, project) \
2559+
do { \
2560+
if (!(store)) { \
2561+
char *_err = build_no_store_error_checked(srv, project); \
2562+
char *_res = cbm_mcp_text_result(_err, true); \
2563+
free(_err); \
2564+
free(project); \
2565+
return _res; \
2566+
} \
25022567
} while (0)
25032568

25042569
static bool project_has_adr(cbm_store_t *store, const char *project, const char *root_path) {
@@ -11627,7 +11692,7 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) {
1162711692

1162811693
char *root_path = get_project_root(srv, project);
1162911694
if (!root_path) {
11630-
char *err = build_no_store_error(project);
11695+
char *err = build_no_store_error_checked(srv, project);
1163111696
char *res = cbm_mcp_text_result(err, true);
1163211697
free(err);
1163311698
free(project);
@@ -12397,7 +12462,7 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
1239712462
* the UI are visible to each other (#256). */
1239812463
store_recovery_status_t recovery_status = STORE_RECOVERY_NONE;
1239912464
cbm_store_t *resolved =
12400-
resolve_store_internal(srv, project, mutation_held, !write_request, &recovery_status);
12465+
resolve_store_internal(srv, project, mutation_held, !write_request, &recovery_status, true);
1240112466
if (!resolved) {
1240212467
char *res = NULL;
1240312468
if (recovery_status == STORE_RECOVERY_BUSY) {
@@ -12406,7 +12471,7 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
1240612471
res =
1240712472
cbm_mcp_text_result("project recovery requires a nonblocking mutation guard", true);
1240812473
} else {
12409-
char *err = build_no_store_error(project);
12474+
char *err = build_no_store_error_checked(srv, project);
1241012475
res = cbm_mcp_text_result(err, true);
1241112476
free(err);
1241212477
}
@@ -12458,7 +12523,7 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
1245812523
invalidate_cached_store(srv);
1245912524
resolved = NULL;
1246012525
store = NULL;
12461-
resolved = resolve_store_internal(srv, project, true, false, NULL);
12526+
resolved = resolve_store_internal(srv, project, true, false, NULL, true);
1246212527
}
1246312528
if (resolved) {
1246412529
store = open_adr_store_for_write(srv, resolved, &owned_rw);

0 commit comments

Comments
 (0)