Skip to content

Commit 997d087

Browse files
authored
Merge pull request #1904 from DeusData/feat/adr-section-update
feat(mcp): add manage_adr mode='set_sections' and splice ADR sections instead of rebuilding
2 parents 1189c87 + d90f98f commit 997d087

6 files changed

Lines changed: 1477 additions & 88 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -666,9 +666,11 @@ JSON arguments can also be piped on stdin, for tools that take arguments. A tool
666666
| `get_code_snippet` | Read source code for a function by qualified name. |
667667
| `get_architecture` | Codebase overview: languages, packages, routes, hotspots, clusters, ADR. |
668668
| `search_code` | Grep-like text search within indexed project files. |
669-
| `manage_adr` | CRUD for Architecture Decision Records. Query modes do not wait behind a same-project reindex; writes remain serialized. |
669+
| `manage_adr` | CRUD for Architecture Decision Records (`get` reads, `update` replaces the whole document, `set_sections` rewrites only the named sections and leaves every other byte untouched, `sections` lists headings). Query modes do not wait behind a same-project reindex; writes remain serialized. |
670670
| `ingest_traces` | Ingest runtime traces to validate HTTP_CALLS edges. |
671671

672+
`manage_adr(mode='set_sections')` writes one or more sections by name and splices them into the stored document, so text outside the named sections — including a preamble, code fences and section ordering — is preserved byte-for-byte. Any `## Heading` works, not just the conventional PURPOSE / STACK / ARCHITECTURE / PATTERNS / TRADEOFFS / PHILOSOPHY set; names match exactly, including case. Writing the same section twice is a no-op, so a retry after a lost response cannot duplicate content.
673+
672674
`manage_adr` query modes (`get` and `sections`) use the server's cached query store so they can proceed while a same-project reindex is running. If another process publishes a replacement store during reindexing, they can return the pre-publication ADR until idle eviction refreshes that cache. Updates remain serialized through the project mutation guard.
673675

674676
## Graph Data Model

‎src/mcp/mcp.c‎

Lines changed: 255 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -706,10 +706,18 @@ static const tool_def_t TOOLS[] = {
706706

707707
{"manage_adr", "Manage ADR", "Create or update Architecture Decision Records",
708708
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"},\"mode\":{\"type\":"
709-
"\"string\",\"enum\":[\"get\",\"update\",\"sections\"],\"description\":\"update replaces "
710-
"the entire ADR document; sections only lists existing "
711-
"headings\"},\"content\":{\"type\":\"string\",\"description\":\"Complete replacement document "
712-
"required by update\"}},\"additionalProperties\":false,"
709+
"\"string\",\"enum\":[\"get\",\"update\",\"set_sections\",\"sections\"],\"description\":"
710+
"\"update REPLACES the entire ADR document; set_sections rewrites only the named sections "
711+
"and leaves every other byte of the stored document untouched, so adding one entry does not "
712+
"mean re-sending the whole ADR (setting the same section to the same body twice leaves the "
713+
"document byte-identical, so retrying after a lost response is safe); sections only lists "
714+
"existing headings\"},\"content\":{\"type\":\"string\",\"description\":\"Complete replacement "
715+
"document required by update\"},\"section_updates\":{\"type\":\"object\",\"description\":"
716+
"\"Required by set_sections: section name -> new body for that section. Any heading name "
717+
"works, so a new entry can be added under its own heading; PURPOSE, STACK, ARCHITECTURE, "
718+
"PATTERNS, TRADEOFFS and PHILOSOPHY are the conventional ones. Names match exactly, "
719+
"including case, so 'Purpose' and 'PURPOSE' are different sections.\","
720+
"\"additionalProperties\":{\"type\":\"string\"}}},\"additionalProperties\":false,"
713721
"\"required\":[\"project\"]}"},
714722

715723
{"ingest_traces", "Ingest traces", "Ingest runtime traces to enhance the knowledge graph",
@@ -11754,31 +11762,33 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) {
1175411762

1175511763
/* ── manage_adr ───────────────────────────────────────────────── */
1175611764

11757-
/* ADR "sections" mode: list markdown headers ('#'-prefixed lines) from the
11758-
* ADR content string. */
11765+
typedef struct {
11766+
yyjson_mut_doc *doc;
11767+
yyjson_mut_val *arr;
11768+
} adr_sections_ctx_t;
11769+
11770+
static void adr_sections_cb(void *ctx, const cbm_adr_heading_t *h) {
11771+
adr_sections_ctx_t *c = (adr_sections_ctx_t *)ctx;
11772+
char hdr[CBM_SZ_1K];
11773+
snprintf(hdr, sizeof(hdr), "## %.*s", h->name_len, h->name);
11774+
yyjson_mut_arr_add_strcpy(c->doc, c->arr, hdr);
11775+
}
11776+
11777+
/* ADR "sections" mode: list the section headings of the ADR.
11778+
*
11779+
* This uses cbm_adr_scan_headings(), the SAME classifier the section-write
11780+
* path splices with. It used to list any '#'-prefixed line, so a '## Foo' in
11781+
* prose — or inside a fenced code block — was reported as a section that no
11782+
* write could target. Two components disagreeing about what a section is was
11783+
* how a section write came to be able to destroy one. */
1175911784
static void adr_list_sections_from_content(yyjson_mut_doc *doc, yyjson_mut_val *root_obj,
1176011785
const char *content) {
1176111786
yyjson_mut_val *sections = yyjson_mut_arr(doc);
11762-
const char *p = content;
11763-
while (p && *p) {
11764-
const char *eol = strchr(p, '\n');
11765-
size_t linelen = eol ? (size_t)(eol - p) : strlen(p);
11766-
while (linelen > 0 && p[linelen - SKIP_ONE] == '\r') {
11767-
linelen--;
11768-
}
11769-
if (linelen > 0 && p[0] == '#') {
11770-
char hdr[CBM_SZ_1K];
11771-
if (linelen >= sizeof(hdr)) {
11772-
linelen = sizeof(hdr) - SKIP_ONE;
11773-
}
11774-
memcpy(hdr, p, linelen);
11775-
hdr[linelen] = '\0';
11776-
yyjson_mut_arr_add_strcpy(doc, sections, hdr);
11777-
}
11778-
if (!eol) {
11779-
break;
11780-
}
11781-
p = eol + SKIP_ONE;
11787+
adr_sections_ctx_t ctx = {doc, sections};
11788+
if (content && cbm_adr_scan_headings(content, adr_sections_cb, &ctx) != CBM_STORE_OK) {
11789+
/* The ambiguity that refuses a section write is reported here too,
11790+
* rather than answering with a heading list that is quietly partial. */
11791+
yyjson_mut_obj_add_str(doc, root_obj, "sections_status", "unterminated_code_fence");
1178211792
}
1178311793
yyjson_mut_obj_add_val(doc, root_obj, "sections", sections);
1178411794
}
@@ -11848,6 +11858,123 @@ static cbm_store_t *open_adr_store_for_write(cbm_mcp_server_t *srv, cbm_store_t
1184811858
return *owned_rw;
1184911859
}
1185011860

11861+
/* Parsed `section_updates` for mode='set_sections'.
11862+
*
11863+
* mode='update' replaces the whole document, so adding one entry costs a full
11864+
* re-send and the stored ADR is only ever as good as that round-trip. Writing
11865+
* named sections instead leaves the rest of the document as the authority for
11866+
* itself — and, unlike a whole-document append, applying the same request
11867+
* twice yields the same document, so a client that retries after a lost
11868+
* response cannot silently duplicate content. */
11869+
typedef struct {
11870+
char *keys[PROPS_MAX];
11871+
char *values[PROPS_MAX];
11872+
int count;
11873+
/* Rejection reason, or NULL when the request parsed cleanly. Set means no
11874+
* store was opened and nothing was written. */
11875+
const char *status;
11876+
const char *error;
11877+
} adr_section_updates_t;
11878+
11879+
static void adr_section_updates_free(adr_section_updates_t *u) {
11880+
for (int i = 0; i < u->count; i++) {
11881+
free(u->keys[i]);
11882+
free(u->values[i]);
11883+
}
11884+
u->count = 0;
11885+
}
11886+
11887+
static bool adr_collect_section_update(adr_section_updates_t *u, yyjson_val *key, yyjson_val *val) {
11888+
const char *name = yyjson_get_str(key);
11889+
if (!name || !name[0]) {
11890+
u->status = "invalid_section_updates";
11891+
u->error = "'section_updates' keys must be non-empty section names. "
11892+
"No ADR write was performed.";
11893+
return false;
11894+
}
11895+
if (!yyjson_is_str(val)) {
11896+
u->status = "invalid_section_updates";
11897+
u->error = "'section_updates' values must be strings (the new body for that section). "
11898+
"No ADR write was performed.";
11899+
return false;
11900+
}
11901+
const char *body = yyjson_get_str(val);
11902+
/* An empty body would render a heading with nothing under it — a silent
11903+
* content deletion wearing the response shape of an update. Clearing a
11904+
* section is whole-document surgery; that is what mode='update' is for. */
11905+
if (!body || !body[0]) {
11906+
u->status = "empty_section_content";
11907+
u->error = "'section_updates' values must be non-empty; use mode='update' to remove a "
11908+
"section. No ADR write was performed.";
11909+
return false;
11910+
}
11911+
if (u->count >= PROPS_MAX) {
11912+
u->status = "too_many_sections";
11913+
u->error = "'section_updates' carries more entries than an ADR can hold. "
11914+
"No ADR write was performed.";
11915+
return false;
11916+
}
11917+
u->keys[u->count] = heap_strdup(name);
11918+
u->values[u->count] = heap_strdup(body);
11919+
if (!u->keys[u->count] || !u->values[u->count]) {
11920+
free(u->keys[u->count]);
11921+
free(u->values[u->count]);
11922+
u->status = "write_error";
11923+
u->error = "out of memory parsing 'section_updates'. No ADR write was performed.";
11924+
return false;
11925+
}
11926+
u->count++;
11927+
return true;
11928+
}
11929+
11930+
static adr_section_updates_t adr_parse_section_updates(const char *args) {
11931+
adr_section_updates_t u;
11932+
memset(&u, 0, sizeof(u));
11933+
11934+
yyjson_doc *doc = yyjson_read(args, strlen(args), 0);
11935+
yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL;
11936+
yyjson_val *updates =
11937+
(root && yyjson_is_obj(root)) ? yyjson_obj_get(root, "section_updates") : NULL;
11938+
11939+
if (!updates) {
11940+
/* Never fall through to 'get': a caller that meant to write must not
11941+
* receive a success-shaped read. */
11942+
u.status = "missing_section_updates";
11943+
u.error = "mode='set_sections' requires 'section_updates', an object mapping section "
11944+
"name to its new body. No ADR write was performed.";
11945+
} else if (!yyjson_is_obj(updates) || yyjson_obj_size(updates) == 0) {
11946+
u.status = "invalid_section_updates";
11947+
u.error = "'section_updates' must be a non-empty object mapping section name to its new "
11948+
"body. No ADR write was performed.";
11949+
} else {
11950+
size_t idx = 0;
11951+
size_t max = 0;
11952+
yyjson_val *key = NULL;
11953+
yyjson_val *val = NULL;
11954+
yyjson_obj_foreach(updates, idx, max, key, val) {
11955+
if (!adr_collect_section_update(&u, key, val)) {
11956+
adr_section_updates_free(&u);
11957+
break;
11958+
}
11959+
}
11960+
}
11961+
yyjson_doc_free(doc);
11962+
return u;
11963+
}
11964+
11965+
/* Build the rejection payload for a set_sections request that never reached a
11966+
* store. Caller frees. */
11967+
static char *adr_section_updates_error(const adr_section_updates_t *u) {
11968+
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
11969+
yyjson_mut_val *root_obj = yyjson_mut_obj(doc);
11970+
yyjson_mut_doc_set_root(doc, root_obj);
11971+
yyjson_mut_obj_add_str(doc, root_obj, "status", u->status);
11972+
yyjson_mut_obj_add_strcpy(doc, root_obj, "error", u->error);
11973+
char *json = yy_doc_to_str(doc);
11974+
yyjson_mut_doc_free(doc);
11975+
return json;
11976+
}
11977+
1185111978
static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
1185211979
char *project = get_project_arg(args);
1185311980
char *mode_str = cbm_mcp_get_string_arg(args, "mode");
@@ -11879,12 +12006,51 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
1187912006
true);
1188012007
}
1188112008

12009+
bool set_sections_mode = (strcmp(mode_str, "set_sections") == 0);
12010+
adr_section_updates_t updates;
12011+
memset(&updates, 0, sizeof(updates));
12012+
char section_key_err[CBM_SZ_256] = "";
12013+
if (set_sections_mode) {
12014+
updates = adr_parse_section_updates(args);
12015+
/* Any heading name is writable — the canonical six are a convention,
12016+
* not a privilege. What is still refused is a name that could not
12017+
* round-trip through a "## NAME" line (empty, '#'-leading, newline- or
12018+
* edge-whitespace-bearing, over-long): such a name would scan back as
12019+
* a different heading or as none, so a second identical write would
12020+
* append a duplicate instead of being a no-op. */
12021+
if (!updates.status && cbm_adr_validate_section_keys(
12022+
(const char **)updates.keys, updates.count, section_key_err,
12023+
(int)sizeof(section_key_err)) != CBM_STORE_OK) {
12024+
adr_section_updates_free(&updates);
12025+
updates.status = "invalid_section_name";
12026+
updates.error = section_key_err;
12027+
}
12028+
if (updates.status) {
12029+
/* Reject before taking the project lease or opening a store: a
12030+
* malformed write must not block an index, and must not read. */
12031+
char *err = adr_section_updates_error(&updates);
12032+
adr_section_updates_free(&updates);
12033+
free(project);
12034+
free(mode_str);
12035+
free(content);
12036+
char *res = cbm_mcp_text_result(err, true);
12037+
free(err);
12038+
return res;
12039+
}
12040+
}
12041+
12042+
/* This classification is load-bearing. A mode missing from it takes no
12043+
* per-project mutation lease, resolves the store query-only, and never
12044+
* reaches open_adr_store_for_write — so its write would be attempted
12045+
* through a read-only handle, concurrently with an active index. */
1188212046
bool write_request =
11883-
content && (strcmp(mode_str, "update") == 0 || strcmp(mode_str, "store") == 0);
12047+
(content && (strcmp(mode_str, "update") == 0 || strcmp(mode_str, "store") == 0)) ||
12048+
set_sections_mode;
1188412049
bool mutation_held = false;
1188512050
if (write_request && project) {
1188612051
mutation_held = mcp_project_mutation_begin(srv, project);
1188712052
if (!mutation_held) {
12053+
adr_section_updates_free(&updates);
1188812054
free(project);
1188912055
free(mode_str);
1189012056
free(content);
@@ -11893,6 +12059,7 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
1189312059
}
1189412060
if (mcp_request_cancelled(srv)) {
1189512061
mcp_project_mutation_end(srv, project);
12062+
adr_section_updates_free(&updates);
1189612063
free(project);
1189712064
free(mode_str);
1189812065
free(content);
@@ -11921,6 +12088,7 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
1192112088
if (mutation_held) {
1192212089
mcp_project_mutation_end(srv, project);
1192312090
}
12091+
adr_section_updates_free(&updates);
1192412092
free(project);
1192512093
free(mode_str);
1192612094
free(content);
@@ -11935,6 +12103,7 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
1193512103
if (mutation_held) {
1193612104
mcp_project_mutation_end(srv, project);
1193712105
}
12106+
adr_section_updates_free(&updates);
1193812107
free(project);
1193912108
free(mode_str);
1194012109
free(content);
@@ -11981,13 +12150,69 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
1198112150
}
1198212151
}
1198312152

12153+
/* A set_sections write must see a legacy file-backed ADR too. The
12154+
* migration above deliberately runs on the read path only — it must never
12155+
* block on the lease — so the write path reads the file here, where the
12156+
* exclusive project lease and a writable store are already held. Merging
12157+
* onto an empty document instead would silently discard an ADR the user
12158+
* still has on disk. */
12159+
char *legacy_seed = NULL;
12160+
if (set_sections_mode && !have_adr) {
12161+
char *root_path = project_root_from_store(store, project);
12162+
legacy_seed = adr_read_legacy_file(root_path);
12163+
free(root_path);
12164+
}
12165+
1198412166
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
1198512167
yyjson_mut_val *root_obj = yyjson_mut_obj(doc);
1198612168
yyjson_mut_doc_set_root(doc, root_obj);
1198712169

1198812170
bool is_error = false;
1198912171
const char *adr_content = have_adr ? adr.content : legacy;
11990-
if (write_request) {
12172+
if (set_sections_mode) {
12173+
/* cbm_store_adr_update_sections requires an existing row — its
12174+
* contract, pinned by TEST(adr_update_no_existing). Seed one when the
12175+
* project has none, so the mode degrades to a plain create: the legacy
12176+
* document when there is one, an empty document otherwise. */
12177+
bool base_present = have_adr;
12178+
bool seeded_empty = false;
12179+
if (!base_present) {
12180+
const char *seed = legacy_seed ? legacy_seed : "";
12181+
if (cbm_store_adr_store(store, project, seed) == CBM_STORE_OK) {
12182+
base_present = true;
12183+
seeded_empty = (legacy_seed == NULL);
12184+
}
12185+
}
12186+
cbm_adr_t updated;
12187+
memset(&updated, 0, sizeof(updated));
12188+
int section_rc = base_present ? cbm_store_adr_update_sections(
12189+
store, project, (const char **)updates.keys,
12190+
(const char **)updates.values, updates.count, &updated)
12191+
: CBM_STORE_ERR;
12192+
if (section_rc == CBM_STORE_OK) {
12193+
yyjson_mut_obj_add_str(doc, root_obj, "status", "sections_updated");
12194+
yyjson_mut_obj_add_str(doc, root_obj, "semantics",
12195+
"named_sections_replaced_rest_preserved");
12196+
yyjson_mut_obj_add_uint(doc, root_obj, "sections_written", (uint64_t)updates.count);
12197+
/* Callers confirm the write landed without re-fetching the ADR. */
12198+
yyjson_mut_obj_add_uint(doc, root_obj, "content_length",
12199+
(uint64_t)strlen(updated.content));
12200+
cbm_store_adr_free(&updated);
12201+
} else {
12202+
/* Undo an empty seed. A rejected write must not leave the project
12203+
* holding a blank ADR where `get` used to answer no_adr. A legacy
12204+
* seed is a real migration and is kept. */
12205+
if (seeded_empty) {
12206+
(void)cbm_store_adr_delete(store, project);
12207+
}
12208+
yyjson_mut_obj_add_str(doc, root_obj, "status", "write_error");
12209+
const char *store_err = cbm_store_error(store);
12210+
if (store_err && store_err[0]) {
12211+
yyjson_mut_obj_add_strcpy(doc, root_obj, "error", store_err);
12212+
}
12213+
is_error = true;
12214+
}
12215+
} else if (write_request) {
1199112216
if (cbm_store_adr_store(store, project, content) == CBM_STORE_OK) {
1199212217
yyjson_mut_obj_add_str(doc, root_obj, "status", "updated");
1199312218
yyjson_mut_obj_add_str(doc, root_obj, "semantics", "whole_document_replaced");
@@ -12018,6 +12243,8 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) {
1201812243
if (mutation_held) {
1201912244
mcp_project_mutation_end(srv, project);
1202012245
}
12246+
adr_section_updates_free(&updates);
12247+
free(legacy_seed);
1202112248
free(legacy);
1202212249
free(project);
1202312250
free(mode_str);

0 commit comments

Comments
 (0)