Upload Files and Save Revisions

An agent can prepare a new file or revision through MCP, transfer the bytes over HTTP, and complete the save through MCP. HTTP integrations use the same upload intents and finish endpoints. Uploading bytes alone does not publish a file. Check the target server’s release: documentation describes the implementation, not whether your infrastructure has deployed it.

MCP workflow

Use a per-user connection with synckhub.mcp.read synckhub.mcp.write. Existing read-only connections need reconnection with write access. There is no extra server consent prompt for each save; the user’s current permissions still apply.

The client must be able to send file bytes over HTTP. A coding agent with local file and HTTP access can perform this sequence. A hosted agent without file-transfer access cannot; a local path in a tool call is not an upload. Acceptance in Next Legal’s actual clients remains unconfirmed.

  1. Resolve the destination folder or existing file using the read tools. For a revision, call get_file_content_head {item_id} and retain blob_id and item_seq from before editing. Never refresh these automatically after a conflict to overwrite someone else’s change.
  2. Generate a UUIDv7 idempotency_key and random UUIDv4 upload_token locally. Retain them for this operation. Calculate lowercase SHA-256 and byte size of the final document. These are client mechanics, not questions for the user.
  3. Call one preparation tool:
    • prepare_file_upload: idempotency_key, upload_token, name, parent_folder_id (null for repository root), sha256, size.
    • prepare_file_revision: idempotency_key, upload_token, item_id, sha256, size, expected_head_blob_id, expected_head_item_seq.
  4. Follow the returned upload instructions: POST its URL and headers with no body, then PATCH the returned Location with the file bytes. Retain the Authorization and Tus-Resumable headers. PATCH also requires Content-Type: application/offset+octet-stream and Upload-Offset. HEAD retrieves the offset after interruption. Zero-byte uploads need no PATCH.
  5. Call complete_file_upload {upload_intent_id}. Only status: COMPLETED confirms the save. The response identifies the item, blob, commit and web URL; revisions also return the content sequence. A revision preserves the item ID and earlier content history. Identical bytes may produce no new revision.

MCP OAuth tokens work only at /mcp. The returned Authorization: Upload … credential stages bytes only for that active intent. It cannot publish content or call ordinary APIs. Keep it private; do not put secrets in logs or URLs.

HTTP workflow

Use a user access JWT authorized for the destination or existing FILE. Current repository API tokens carry ADMIN authority, but Community upload admission checks stored membership; do not assume those machine credentials support this workflow. Restricted service principals are separate work.

These examples require Bash, curl, jq and Python 3. Set SYNCKHUB_API_BASE_URL, SYNCKHUB_ACCESS_TOKEN, SYNCKHUB_REPO_ID and FILE_PATH. For a revision, first capture the original head as shown below, before editing; retain that JSON file as HEAD_FILE. Keep response files private and preserve this working directory until the operation is reconciled.

set -euo pipefail
umask 077
WORK_DIR=$(mktemp -d)
python3 - "$FILE_PATH" > "$WORK_DIR/write.json" <<'PY'
import hashlib, json, secrets, sys, time, uuid
key = (int(time.time() * 1000) << 80) | (7 << 76) | (secrets.randbits(12) << 64) | (2 << 62) | secrets.randbits(62)
hash_value = hashlib.sha256()
size = 0
with open(sys.argv[1], 'rb') as source:
    for chunk in iter(lambda: source.read(1024 * 1024), b''):
        hash_value.update(chunk)
        size += len(chunk)
print(json.dumps({'id': str(uuid.UUID(int=key)), 'token': str(uuid.uuid4()),
                  'sha256': hash_value.hexdigest(), 'size': size}))
PY
INTENT_ID=$(jq -er '.id' "$WORK_DIR/write.json")
UPLOAD_TOKEN=$(jq -er '.token' "$WORK_DIR/write.json")
FILE_SIZE=$(jq -er '.size' "$WORK_DIR/write.json")

Choose exactly one admission request. For a new file, set FILE_NAME and FOLDER_ID (empty for root). Name collisions fail; no silent replacement occurs.

INTENT_KIND=file-intents
jq --arg repo "$SYNCKHUB_REPO_ID" --arg name "$FILE_NAME" --arg parent "$FOLDER_ID" \
  '{repo_id:$repo,name:$name,parent_folder_id:(if $parent=="" then null else $parent end),
    upload_token:.token,write:{sha256:.sha256,size:.size}}' \
  "$WORK_DIR/write.json" > "$WORK_DIR/request.json"

For a revision, set ITEM_ID and HEAD_FILE to a private output path. Read and retain the head before editing the original document, then use that saved head when preparing the final bytes. Original bytes are available at /repos/{repo}/blobs/{blob_id} with authorized HTTP credentials, independently of text extraction or OCR.

curl --fail-with-body --silent --show-error \
  --header "Authorization: Bearer $SYNCKHUB_ACCESS_TOKEN" \
  "$SYNCKHUB_API_BASE_URL/repos/$SYNCKHUB_REPO_ID/items/$ITEM_ID/content-head" \
  > "$HEAD_FILE"
# Perform this read before editing; retain HEAD_FILE while preparing write.json.
INTENT_KIND=file-content-intents
jq --arg repo "$SYNCKHUB_REPO_ID" --arg item "$ITEM_ID" --slurpfile head "$HEAD_FILE" \
  '{repo_id:$repo,item_id:$item,upload_token:.token,write:{sha256:.sha256,size:.size,
    expected_head_blob_id:$head[0].blob_id,expected_head_item_seq:$head[0].item_seq}}' \
  "$WORK_DIR/write.json" > "$WORK_DIR/request.json"

Admit, transfer, and finish the chosen operation:

curl --fail-with-body --silent --show-error \
  --header "Authorization: Bearer $SYNCKHUB_ACCESS_TOKEN" \
  --header "Idempotency-Key: $INTENT_ID" --header 'Content-Type: application/json' \
  --data-binary @"$WORK_DIR/request.json" \
  "$SYNCKHUB_API_BASE_URL/v1/uploads/$INTENT_KIND" > "$WORK_DIR/intent.json"
INTENT_B64=$(printf '%s' "$INTENT_ID" | base64 | tr -d '\r\n')
TOKEN_B64=$(printf '%s' "$UPLOAD_TOKEN" | base64 | tr -d '\r\n')
curl --fail-with-body --silent --show-error --request POST \
  --header "Authorization: Bearer $SYNCKHUB_ACCESS_TOKEN" --header 'Tus-Resumable: 1.0.0' \
  --header "Upload-Length: $FILE_SIZE" \
  --header "Upload-Metadata: upload_intent_id $INTENT_B64,upload_token $TOKEN_B64" \
  "$SYNCKHUB_API_BASE_URL/tus/files"
if [ "$FILE_SIZE" -gt 0 ]; then
  curl --fail-with-body --silent --show-error --request PATCH \
    --header "Authorization: Bearer $SYNCKHUB_ACCESS_TOKEN" --header 'Tus-Resumable: 1.0.0' \
    --header 'Content-Type: application/offset+octet-stream' --header 'Upload-Offset: 0' \
    --data-binary @"$FILE_PATH" "$SYNCKHUB_API_BASE_URL/tus/files/$INTENT_ID"
fi
curl --fail-with-body --silent --show-error --request POST \
  --header "Authorization: Bearer $SYNCKHUB_ACCESS_TOKEN" \
  "$SYNCKHUB_API_BASE_URL/v1/uploads/$INTENT_KIND/$INTENT_ID/finished" \
  > "$WORK_DIR/finished.json"

This example sends one PATCH. Large files should use bounded chunks and resume with HEAD/Upload-Offset. The tus OPTIONS response advertises Tus-Max-Size. Keep the source bytes unchanged until completion. Bound revisions use atomic expected-head checks and reject conflicting locks; no lock acquisition step is needed. The older unbound revision workflow has different lock/concurrency rules.

Retries and failures

Situation Required behavior
Lost admission response Repeat the exact key, secret and request. Changed context returns 409.
Interrupted transfer HEAD the same tus resource, resume at its offset. Repeated matching POST never truncates it.
Lost completion response Complete the same intent again; recover the recorded result, not another file/revision.
Head changed or file locked Stop on 409. Resolve the edit conflict; never force unlock or refresh the head silently.
Incorrect hash/size or incomplete transfer Completion fails without publishing the bytes.
Permission revoked Admission, transfer, completion and replay recheck current authority.
Abandoned intent expired Existing upload cleanup marks it failed. Reconcile before starting a new operation.

Completed upload identities/results are durable. Active uploads follow the server’s existing abandonment policy; the upload capability ends on completion or failure. Idempotency-Key applies to these two admission routes, not all APIs. 401 means credentials need renewal; 403/404 can indicate denied authority. tus also uses 412 for protocol version, 413 for size/capacity and 415 for PATCH media type. Its error bodies need not be JSON. MCP structured errors include code.

VCS history records the acting user. Successful MCP audit records correlate client, grant, intent, item and commit without storing upload secrets. Existing authorized HTTP audit/history routes expose those records; MCP has no audit tool. Local standalone FILEs are supported; federated files and email conversion are outside this workflow. A content revision is not a named business version.

Organization and recovery through MCP

The same user connection now exposes folder creation, rename, move, supported metadata, trash/restore, named file versions, historical revision restore, exclusive locks, shared file links, and existing task/post comments.

Tools Inputs
create_folder idempotency_key, name, parent_folder_id (null for root)
rename_item idempotency_key, item_id, name
move_item idempotency_key, item_id, parent_folder_id
update_item_metadata idempotency_key, item_id, meta_patch
trash_item, restore_item idempotency_key, item_id
create_file_version idempotency_key, item_id, expected_head_item_seq; optional kind (MAJOR/MINOR), comment
restore_file_revision idempotency_key, item_id, revision_item_seq, expected_head_blob_id, expected_head_item_seq
acquire_file_lock item_id; optional ttl_seconds
release_file_lock item_id, exact lock_token
create_file_shared_link idempotency_key, item_id
revoke_file_shared_link shared_file_link_id
add_task_comment idempotency_key, task_id, body
add_post_comment idempotency_key, post_item_id, body_markdown

Generate one UUIDv7 per keyed operation and retain the exact arguments when retrying. Reusing a key for a different user, repository, tool or request fails. Completed retries preserve their original result and do not undo later edits. Current permissions still apply, including on replay. Folder organization and revision restore require local authority; they do not write federated mirrors.

Trash additionally requires synckhub.mcp.delete. A read/write connection cannot trash items. Request read, write and delete when connecting if trash is needed; the connection screen describes that access once. No per-action consent prompt is added. These scopes act on behalf of the connected user, not an autonomous agent identity, and never grant permissions the user does not have.

Trash has no automatic expiry, but no guaranteed recovery period is promised. Permanent administrative deletion or repository erasure can remove recovery options. Revision restore creates a new current head while preserving later history. Shared links grant public access; revocation disables them, and retrying a completed creation does not recreate a revoked link.

Locks are temporary editing leases. Do not force unlock another editor. Releasing your own lock can publish your pending editor draft. Metadata updates preserve existing validation; color_metadata supports the six existing colors or null. Named tags are a separate feature. Markdown notes use file writes; task and post comments use their own typed tools. There is no arbitrary-file comment API.

See the full server reference for equivalent HTTP routes and their individual retry contracts.

Download and read long documents

get_file_download {item_id} returns a short-lived authorized URL for the original bytes, filename, size and expiry. It works when extraction is pending, empty or failed. Treat this URL as a private credential.

Use inspect_item to check content_hints.text_availability. The state distinguishes ready, pending, empty, failed, unavailable, not-derived and unsupported output. A file type being supported does not mean its text is currently available.

Call read_file_page {item_id,max_bytes:50000}. Retain the returned source and continue with the same item_id and next_cursor until the cursor is null. Pages contain complete UTF-8 characters and report the published text’s content_bytes. A changed revision returns CONTENT_CHANGED: restart rather than combine pages. The page limit is 1024–50000 bytes. Legacy max_chars also counts bytes.

derivation_truncated reports whether extraction capped the published text: true, false, or null when unknown. Paging reads all published text; it cannot recover text omitted during conversion. Current direct extraction accepts source files up to 100 MB and caps published Markdown at 4 MB.

For an image-only PDF, call get_file_ocr_status {item_id}. If eligible, explicitly call request_file_ocr with that status’s content_blob_id and content_item_seq as expected_content_blob_id and expected_content_item_seq. Repeated requests for the same source reuse the attempt. A failed attempt can be retried with its observed retry_of_revision. Write access and current file permissions apply. OCR leaves original bytes intact; reads never silently start it. Encrypted and mixed/image-text PDFs are not newly supported.