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.
- Resolve the destination folder or existing file using the read tools. For a
revision, call
get_file_content_head {item_id}and retainblob_idanditem_seqfrom before editing. Never refresh these automatically after a conflict to overwrite someone else’s change. - Generate a UUIDv7
idempotency_keyand random UUIDv4upload_tokenlocally. 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. - 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.
- Follow the returned
uploadinstructions: 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 requiresContent-Type: application/offset+octet-streamandUpload-Offset. HEAD retrieves the offset after interruption. Zero-byte uploads need no PATCH. - Call
complete_file_upload {upload_intent_id}. Onlystatus: COMPLETEDconfirms 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.