GData Server — Troubleshooting

Double-encoded documents

Symptom: calling patch on a note returns "document is not a JSON object".Root cause: the GDBM value is a JSON string literal (the document JSON serialised to a string and then serialised again), not a JSON object. The patch handler does json.loads → gets a Python str → fails isinstance(doc, dict).How it happened: old put handler called json.dumps(string) instead of json.dumps(parsed_dict). The current handler fixes this via _parse_json_robust (unwraps before storage). Existing double-encoded docs must be repaired manually.

Additional symptom of double-encoding via MCP put: the response includes "block_ids": [] (empty list). A correctly stored JSONHTL document returns populated block IDs. Use this as a quick sanity check after any MCP put of a complex document.

Fixing a double-encoded document

python3 -c "
import json
with open('/path/to/file') as f:
    code = f.read()
doc = {
    'title': 'my-note',
    'content': [
        {'para': ['Some text.']},
        {'codeblock': {'lang': 'perl', 'body': code}}
    ]
}
print(json.dumps(doc))
" | ssh john@gravlax.critchley.biz "curl -s -X PUT 'http://127.0.0.1:8020/my-note' -H 'Content-Type: application/json' -d @-"

The safest way to PUT a large or code-containing document is to generate the JSON locally and pipe it to gravlax over SSH. This bypasses the MCP tool entirely and avoids double-encoding:

Preferred fix: pipe from local machine via SSH

# 1. Get the raw value and extract inner JSON
curl -s http://127.0.0.1:8021/KEY | python3 -c "
import json,sys
outer = json.load(sys.stdin)
# If outer is a string, it's double-encoded
if isinstance(outer, str):
    inner = json.loads(outer)
    print(json.dumps(inner, indent=2))
else:
    print('Not double-encoded')
" > /tmp/fixed.json

# 2. Inspect /tmp/fixed.json, correct any content issues

# 3. PUT back
curl -s -X PUT http://127.0.0.1:8021/KEY \
  -H 'Content-Type: application/json' -d @/tmp/fixed.json

If the inner JSON is also malformed (truncated, missing braces etc): write the document fresh. Do not try to patch a corrupt JSON string — reconstruct and PUT. This is faster.

Patch operations that exist

Connection refused on 127.0.0.1:8021 / correct ports

The notes server on gravlax listens on 127.0.0.1:8020 (HTTP API) and 127.0.0.1:8023 (MCP). Port 8021 appears in older docs but is wrong. The stunnel exposes these externally on 18021 and 18023. If connection is refused, check the stunnel client on pomelo (/etc/stunnel/) or restart stunnel4.

Connection refused on 127.0.0.1:8021

The stunnel client on pomelo is not running. The notes server itself (on gravlax) is separate. Check /etc/stunnel/ or restart the stunnel service on pomelo.

Batch ops apply sequentially — plan against the moving document

Ops within a single batch are applied one after another, and each op sees the document as left by the previous ops in the same batch — not the document as it was when you read it. Block-ID targets stay valid (IDs are stable), but if you mix deletes and positional inserts (insert_after/insert_before) in one batch while mentally planning every target against the original layout, later ops land in the wrong place. Observed 2026-06-28: a CONTENTS edit that deleted duplicate lines and inserted new section links in the same batch scattered links into wrong sections and deleted an unrelated entry.

Guidance: (1) prefer targeting by block_id over position; IDs do not drift even as siblings move. (2) Keep a single batch to one kind of structural change — do deletions in one batch, then re-read, then do inserts. (3) For anything beyond a couple of structural ops, especially reorganising sections, reconstruct the whole document and put it — a clean full replace is more reliable than a long positional batch and avoids the moving-target trap entirely. (4) Always re-read and eyeball the result after a structural batch.

Preferred editing pattern: delete → append → reorder

When editing a document with multiple structural changes, avoid positional inserts entirely. Use this three-step pattern instead:

1. Delete — call delete_block (by stable block_id) for every block being removed or replaced. Block IDs are stable so these are safe regardless of order.

2. Append — call append_block for each new or replacement block. Order does not matter yet; each append returns a fresh block_id. Collect these IDs.

3. Reorder — call reorder with the complete desired list of all remaining block_ids (original survivors + new append IDs) in final order. This places everything correctly in one atomic operation.

This avoids all positional drift. The only state needed is the block_id list from the initial get(include_block_ids=true) plus the IDs returned by each append. The final reorder list can be computed before any calls are made.

The two patch steps can be collapsed into one using batch: send all delete_block and append_block ops together in a single batch call. It returns all new block IDs in one response. Then call reorder. Two MCP calls total instead of one per block.

List-wrapped documents from notes load (distinct from double-encoding)

Symptom (found 2026-07-08, popit3/outlook-token-renewal): notes patch <key> append_block ... fails with "document is not a JSON object" — same generic error as double-encoding above, but the cause and fix are different. Calling notes read <key> showed a top-level JSON array containing one object with keys op, name, title, content (i.e. an ops-file / patch-request shape: [{"op": "upsert", "name": ..., "content": [...]}]), not a plain {title, content} document.

Root cause (inferred, not confirmed server-side): the document was originally created with notes load <key> <file> where <file> was itself an ops-list (the format notes patch documents as its own CLI argument shape, e.g. from copy-pasting a patch payload), rather than a plain document object. load appears to store whatever JSON it is given verbatim as the document content — it does not detect or apply an ops-shaped input, so the wrapper itself becomes the permanent stored document. Every later patch on that key then fails, because the stored value is a list, not an object with a content array.

Fix used: notes read <key> to get the malformed list, extract [0] (the inner object), rebuild a proper {title, content} document keeping title and content (dropping op / name), then notes load <key> <fixed-file> to overwrite. After that, patch worked normally again. Same repair shape as the double-encoding fix above (reconstruct + load/PUT, do not try to patch a corrupt shape) — the only difference is what the corruption looks like (list-of-ops vs. double-stringified) and how to recognise it (isinstance(outer, list) here vs. isinstance(outer, str) there).

Lesson: fixing _parse_json_robust means checking every call site, in both files

gdata_server.py and gdata_mcp_server.py each carry their own copy of _parse_json_robust (kept in sync by a code-comment convention, not by import). gdata_mcp_server.py alone calls it from six separate spots: the REST route's batch ops parsing, and the MCP tool handlers for put (value), patch (block/fields), batch (ops), reorder (order), and table_op (values/order/columns/by). The REST route and the MCP batch tool are two genuinely separate code paths that happen to do the same thing -- fixing one and assuming the other is covered is exactly how this bug survived a first fix attempt on 2026-07-08.

Confirmed two additional instances beyond the original bug report during that fix: (1) the MCP batch/reorder tool handlers caught the _parse_json_robust failure but discarded its message, falling back to a generic "'ops'/'order' could not be parsed as JSON" -- so an improved error message in the helper itself never reached the caller on that path. (2) gdata_mcp_server.py's own REST batch handler (separate from the MCP batch tool, hit when POSTing directly to /{key}) still used plain json.loads with no apostrophe tolerance at all.

How this was caught: grepped for every json.loads( call in gdata_mcp_server.py and manually classified each one (client-input parsing vs. internal storage reads) rather than trusting that fixing the named function alone was sufficient. When asked to fix a parsing bug in this codebase, grep for all call sites of the relevant helper (and for the raw json.loads/json.dumps patterns it's meant to replace) in both gdata_server.py and gdata_mcp_server.py before declaring it fixed.

version 3  ·  created 2026-06-25  ·  updated 2026-07-08