Skip to content

Commit 704efef

Browse files
feat(mcp): read_file and create_upload_link read and attach a record's files (#31009)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DRxqCFSpBBvnTxKi4CsWK3E)_ domain:server#4 · os-elon-musk · session_016WV4cyqd3A7fbCWrqM3KCz Fixes #30892 Clause-②: yes (widening) — two MCP tools join the HTTP surface where the storage service is installed (`read_file` under `data:read`, `create_upload_link` under `data:write`), and two signed-in routes join the storage family (`GET` and `PUT /api/v1/storage/upload-links/:token`). Nothing that answered before answers differently. Metadata-protocol: none Claim: 6691779259 (`domain:server#2`, take-over on handoff 6690566522; seat 4's claim 6685332126 released) · Branch: `claude/project-thread-s2vcdk` · Rulings: option B, sign-in required (6691366983); D1 / D2 / D3 from the seat (D1 below) · Triage: 6685125943 · Contract review: 6691738005 (PASS, 0 must-fix; should-fix 1 and 2 done on this head) ## 维护者速读 - 改了什么:MCP 新增两个工具。`read_file` 读一条记录上的文件(文件字段或附件),返回文本(PDF、Word 在服务端抽取,图片作图片块)、短时下载链接、大小和读取上限;`create_upload_link` 给一条可编辑记录的某个文件字段(或附件列表)发一个 10 分钟内只能用一次、只限本人登录后使用的上传链接,用户在已登录的浏览器里打开选文件即可。 - 为什么改:卡片 #30892,agent 以前既读不到记录上的文件,也没法把文件挂到记录上。权限完全跟随记录:读不到记录就答 `get_record` 同样的 404,下载门会拒的文件答下载门同样的 403。 - 风险与回滚:新增两条需要登录的路由(上传链接页和 `PUT`),未登录答 401,不是匿名入口。按你的裁决(方案 B,6691366983),链接只限发链接的本人登录后使用。令牌 256 位随机、库里只存 SHA-256、条件更新保证只用一次、过期 10 分钟;已用/过期/不存在/别人的链接答同一个 404。链接行里记的使用人必须等于该行只读的上传人 `owner_id`,有 `sys_file` 编辑权的人改写链接行也拿不到链接。上传以发链接的用户身份重新解析权限后落库,走记录自己的更新门。新依赖 `unpdf` 1.9.1 只在 `@objectstack/mcp`、按需加载。回滚即撤 PR。 - 先看:① `packages/services/service-storage/src/record-file-access.ts`(`openLink` 的各项检查,以及 `consumeUploadLink` 的顺序:开链、校验、大小门、重解析身份、条件更新花掉链接、落库);② `packages/services/service-storage/src/storage-routes.ts` 的 `upload-links` 两条路由;③ `packages/mcp/src/file-content.ts`(PDF/Word 抽取与各上限常量)。 - 两分钟验证:`pnpm --filter @objectstack/service-storage exec vitest run src/record-file-access.integration.test.ts`,预期 22 条全绿:访问跟随记录、大小上限、单次使用(并发两次只成一次)、过期、绑定一条记录一个字段、未登录 401 与他人 404 都不花掉链接、改写过使用人的链接行答 404 且不花掉、超限 413 不花掉链接。 - 需要你做的:本 PR 改动 4,000 多行,超过 3,000 行的人工审核门槛,需你经 os-zhuang 批准一次,之后由 server 席位 2 落地。 ## Why #30892: an agent connected over MCP could read a record's fields but not the files on it (a receipt, a signed contract, a photo), and had no way to put a file on a record. The card's ruling is two tools, access following the record, a named size cap, and a short-lived single-use upload link bound to one record and field; direct protocol upload stays out of scope. ## What changes **`@objectstack/service-storage`** — the record's file doors, as the caller. - `record-file-access.ts` (new) holds three caller-scoped verbs on the `file-attachments` service: `listRecordFilesAs`, `readRecordFileAs`, `createUploadLinkAs`, plus the link door the routes call (`openUploadLink`, `consumeUploadLink`). - The record is read as the caller first (`findOne` under the caller's context). Unreadable, missing or refused answers `404 RECORD_NOT_FOUND`, the sentence `get_record` answers. - A file field's id is read off the record; an attachment is found by a caller-scoped `sys_attachment` read bound to that record, so an attachment id from another record answers `404`. A field holding several files asks for `fileId` and lists the ids. - The file's read is judged by the download door's own verdict (`judgeFileRead`, the plugin's, unchanged and bound in; ADR-0104 public-read, attachments parent read, field owner). A refusal answers that door's `403 ATTACHMENT_DOWNLOAD_DENIED` / `FILE_DOWNLOAD_DENIED`. A file that is not committed (or deleted and not held) answers `404 FILE_NOT_FOUND`. No second access judgment. - Bytes are read only when the size is within the caller's cap; the download link is the doors' own `mintDownloadUrl` with the doors' TTL (300 s). - **The upload link.** `createUploadLinkAs` asks the record's edit door before minting: attachments use `assertMayAttach` (the attach door's verdict); a field uses `mayEditAttachmentParent` (`403 PERMISSION_DENIED`) and the caller's writable fields (`403 FIELD_NOT_ACCESSIBLE`). The link is one pending `sys_file` row whose `name` holds its state, whose `metadata` holds the binding (object, record, field or attachments, user, organization, expiry, secret digest), and whose `owner_id` (readonly) is the minting user. - **Spending a link**, in this order: the request's own session (none: `401 AUTH_REQUIRED`); open (token shape, row, state, digest, expiry, the session's user is the binding's user, and the binding's user is the row's `owner_id`); validate the body; the size gate (`uploadSizeRefusal`, `413 PAYLOAD_TOO_LARGE`) before anything is spent; re-resolve the minting user (`resolveUserAuthzGrants` with the grants cache bypassed, and `vetOrganizationClaim` on the bound organization; unbacked answers `403 PERMISSION_DENIED`); the conditional update that spends the link; then the write as that user. A field upload is written through `engine.update` as the user, so the record's write RLS, the field's `accept` / `maxSize` and the file-reference hooks apply exactly as for any edit; an attachment goes through `attachFileAs`. Any refusal after the spend discards the bytes and the row. The link row is deleted after use. - `metadata-store.ts`: `updateFileIfUnchanged(store, id, seen, patch)` — a conditional `update` on `{ id, status, name }` with `multi: true`, answering how many rows moved. (The JSON `metadata` cannot be the guard: a `{…}`-shaped string where-value is read as a filter placeholder.) - `storage-routes.ts`: `GET /api/v1/storage/upload-links/:token` (the page) and `PUT …/:token` (the upload), mounted through `mountFamilyRoute` (the ADR-0069 session gate applies) and ledgered `server-only` (`storage-route-ledger.ts`). Both read the request's session first and answer `401 AUTH_REQUIRED` without one, failing closed when no session resolver is wired, so neither is an anonymous door (ADR-0138 D2). `mintDownloadUrl` and `DEFAULT_DOWNLOAD_TTL` are now exported so the doors and the verbs share them. `upload-link-page.ts` renders the page; `upload-link-wire.ts` holds the one unusable-link status, code and sentence. - `storage-service-plugin.ts` binds the verbs to the plugin's own `judgeFileRead`, servability check and the kernel's authz resolution (`bindRecordFileAccess`). **`@objectstack/mcp`** - `read_file` (`objectName`, `recordId`, and `field` with optional `fileId`, or `attachmentId`; neither lists the record's files) answers a facts block (id, name, type, size, field or attachment, `maxBytes`, download link) and then the content: text for text types, PDF and `.docx`; an MCP image block for an image up to 5 MiB. Over the 10 MiB cap it answers the facts and the link without content. - `create_upload_link` (`objectName`, `recordId`, and `field` or `attachments: true`) answers `url`, `expiresAt`, `singleUse: true`, the binding, and one line of instructions (open it in a browser where the user is signed in to this deployment). - Both register only where the bridge carries the members, under the OAuth scope gates (`data:read`, `data:write`), refuse `sys_*` objects as the other data tools do, and render refusals with the ADR-0112 envelope. - `file-content.ts` (new): the extractors and the named bounds — `READ_FILE_MAX_BYTES` 10 MiB, `READ_FILE_TEXT_MAX_CHARS` 100 000, `READ_FILE_PDF_MAX_PAGES` 200, `READ_FILE_DOCX_XML_MAX_BYTES` 32 MiB, `READ_FILE_IMAGE_MAX_BYTES` 5 MiB. Every extractor answers a sentence on a bad file (encrypted, damaged, no text layer, not a Word document); a net around them keeps a file from ever failing the tool. - `skill-md.ts`: the served `SKILL.md` teaches each tool only where it is registered (`fileTools` option, scope-gated). **`@objectstack/runtime`** — `domains/mcp.ts`: the bridge's file members read the record through `get_record`'s door (`callData('get')`) FIRST, so an unreadable record is that door's refusal and storage is never asked; then call the storage verbs as the request's principal. A local download path and the upload link's path come back absolute on the deployment's public origin (the auth service's MCP resource URL, else the request's host and forwarded proto); an adapter's absolute URL is left as it is. Without the storage service the members are absent and neither tool is listed. **Docs** — `content/docs/ai/connect-mcp.mdx` (two tool rows and a "Files" section), `content/docs/api/plugin-endpoints.mdx` (the two routes). Two censuses the gates hold to the tree: `content/docs/permissions/tenant-audit-census.mdx` and its counts ledger (268 engine write call sites on the merged tree: `main`'s 266 plus the link's field write and the conditional spend; regenerated by `tenant-audit-census.mjs --write`, the page's hand-stated figures moved to match), and `content/docs/permissions/system-context.mdx` row 47e, which now names the record-file verbs beside the attach verbs (both refuse the system context through a `requireUserContext`, so the row's key is pinned per file). ### D1 — the PDF text extractor (seat ruling) `unpdf` at exactly `1.9.1` (pinned, no caret), a dependency of `@objectstack/mcp` only, loaded by dynamic `import()` on the first PDF read. Reasons, as ruled: it is pdf.js packaged for server runtimes with no native dependency (its `@napi-rs/canvas` peer is optional and not installed), so the text layer is pdf.js's; 1.9.1 rather than 2.x keeps the engines floor the workspace already has; the dynamic import keeps the cost off every MCP request that never reads a PDF. It runs with `isEvalSupported: false` (pdf.js compiles no font code with `eval`), `disableFontFace: true`, `useSystemFonts: false`, only on bytes that already passed the 10 MiB cap, and stops at 200 pages or 100 000 characters. A malformed or encrypted PDF answers a sentence, pinned. `.docx` is read with `node:zlib` (`inflateRawSync` with `maxOutputLength` at 32 MiB) over a minimal ZIP reader that opens only `word/document.xml`; a decompression bomb answers a sentence, pinned. `pnpm-lock.yaml` carries only the three `unpdf` hunks. ### D2 and D3 (seat: as recommended) - **D2**: the link record is a pending `sys_file` row plus its `metadata`, so no new object and no spec change. - **D3**: the upload is judged twice. At mint, as the MCP caller (the agent's own ceiling included): the attach verdict, or the record read, the record-update verdict and field writability. At upload, as the minting user with grants re-resolved, through the existing caller verbs (`attachFileAs`, or the stored file plus the record's own update as that user), so field security, RLS and validation judge it. The alternative, judged at mint and written as the system (the signed-URL precedent), was not taken. ### The upload-link security surface - **Token**: `UUID.SECRET`, where the UUID is the link row's id and the secret is 32 random bytes (`randomBytes`, 256 bits) in base64url. Shape-checked before any read. - **At rest**: only the SHA-256 of the secret is stored, in the row's `metadata`; it is compared with `timingSafeEqual`. The secret is in no log and no answer after the mint. - **Single use**: a conditional update moves the row from `name: 'upload-link'` to `'upload-link (used)'` where `id`, `status: 'pending'` and the open name still hold, and the spend counts only when exactly one row moved. Two racing uploads: one `200`, one `404` (pinned in both suites). - **Expiry**: 600 s from the mint, checked when the page opens and again when the upload arrives. An expired, never-used link row stays `pending` and is reaped by the existing pending-upload retention (`attachment-lifecycle.ts`, 7 days). - **Whose link it is**: only the user it was minted for, signed in (ruling 6691366983). No session answers `401 AUTH_REQUIRED` on both routes and spends nothing; a session naming another user gets the unusable-link answer below and spends nothing (both pinned). - **The binding cannot be moved to another user.** The row's `name` and `metadata` are not `readonly` columns, so the link opens only when the binding's user is the row's `owner_id`, the readonly uploader column written at mint. A row whose binding was rewritten to name another user answers the unusable-link `404` to that user and to the minting user alike, and is not spent (pinned, ablated). The binding's other columns (record, field, organization) rest on `sys_file` edit being an administrative grant, as the module doc states. - **One answer for used, expired, unknown, malformed and another user's**: page `404` with one page; upload `404 RESOURCE_NOT_FOUND`, "This upload link cannot be used: it was used already, it has expired, it was made for someone else, or it does not exist. Ask for a new link." - **The page**: `Content-Security-Policy` with a per-response nonce (`default-src 'none'; script-src 'nonce-…'; style-src 'nonce-…'; connect-src 'self'; form-action 'none'; frame-ancestors 'none'; base-uri 'none'`), `Cache-Control: no-store`, `Referrer-Policy: no-referrer`, `X-Robots-Tag: noindex`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`. It names no record and no user. Its script `PUT`s to its own address with `credentials: 'same-origin'`, carrying the session cookie the route asks for. - **Whose upload it is**: the minting user's, re-resolved at use, so access revoked between mint and use refuses the upload (`403`, nothing lands; pinned). The binding comes from the row only; headers and query strings naming another record or field are ignored (pinned). ## Tests - `packages/services/service-storage/src/record-file-access.integration.test.ts` (new, 22): a real ObjectQL on SQLite with the plugin's own wiring and a write-side RLS fixture — read and its cap, access on read (unreadable record, download-door refusal), multi-file fields, a non-file field, attachments and the listing; the mint (token shape, TTL, secret not stored), access on mint (`404`, `403 PERMISSION_DENIED`, attachments parent), the page's headers, the binding (headers ignored), the attachments link, no session (`401`, not spent), another signed-in user (`404`, not spent), a row whose binding was rewritten to another user (`404` for both users, not spent), single use (forged, unknown, malformed: one `404`), the race, expiry, the `413` that does not spend the link, revoked access (`403`, nothing lands), and a field's `maxSize` refused with the bytes removed. - `packages/mcp/src/mcp-file-tools.test.ts` (new, 21): both tools over the HTTP transport, argument refusals, the listing, a real PDF's text through `unpdf`, a damaged PDF, a `.docx`'s text, a `.docx` bomb, an image block, the over-cap answer, and the skill lines per scope. - `packages/runtime/src/mcp-record-file-bridge.test.ts` (new, 5): the record is read through `get_record`'s door first, the principal, absolute links, no members without the seam. - `packages/qa/dogfood/test/showcase-mcp-record-files.dogfood.test.ts` (new): the booted showcase with security, storage and MCP, a member and the admin over `/api/v1/mcp` — `showcase_invoice_line.receipt` (a file field under `controlled_by_parent`) and `showcase_invoice` attachments (owner RLS): mint, page, upload with a forged target, anonymous `401` and another signed-in user's `404` without spending the link, the committed `sys_file` row of its byte size, second use, unknown token, expiry, `read_file` of the PDF's text and its download bytes, the admin's records refused with `get_record`'s code on both tools, an attachment of another record unreadable through the member's own, the race, and the `413` before the spend. - Updated: the storage route count pins (10 → 12), the ledgered-route list, the MCP unknown-argument sweep (fifteen tools with main's delete_records) and skill surface guard (the two tools), and a comment in the route-ledger parity dogfood. ## Acceptance notes - **Ruled: the link needs a sign-in (option B, ruling 6691366983).** The first build served the link as a bearer capability; the booted anonymous-door sweep (ADR-0138 D2) put its two routes in the everything-else class, which must answer `401`. The maintainer ruled B: the link works only for the user it was minted for, signed in, built in `04ff76bc`. Both routes answer `401 AUTH_REQUIRED` without a session, and the sweep holds unchanged with no ADR change. The cost, accepted with the ruling: a person opening the link on a phone that is not signed in signs in there first, a forwarded link works for nobody else, and an agent uploading by itself needs a deployment credential. Option A (a bearer link plus an ADR-0138 amendment) is not taken. - **Contract review 6691738005: PASS, 0 must-fix.** Should-fix 1 (the owner check in `openLink`) is in `8d23d823`; should-fix 2 is this body. Should-fix 3 (a signed-out browser opening the link gets a JSON `401`, not a sign-in page) is a follow-up card for the storage lane; the `401` rule is unchanged here. - **Over 3,000 changed lines.** The diff is about 4,070 changed lines with the lockfile and the census ledger, so it lands only after an authorized approval, then by the owning seat. The owner check adds 34 of them. - **Merged, not rebased.** `main` (with #30861, which also touches `mcp-http-tools.ts`, the unknown-argument sweep and `connect-mcp.mdx`) is merged in at `c5fe7df9`; that merge was clean. `main` was merged again at `57724d86`, where the only conflict was the tenant-audit census: both sides had moved its figures, so the artefacts were taken from `main`, regenerated over the merged tree, and the page's hand-stated figures moved to match (`check-tenant-audit-census` green). The branch was already pushed, so it was not rebased. `main` was merged a third time at `c2a3229b`, after #31024 (`delete_records`) landed. Three files conflicted, all on the MCP tool list. `content/docs/ai/connect-mcp.mdx` now reads "Thirteen tools, generated from your metadata, and two more for files where the storage service is installed", and its `tools/list` comment reads "the thirteen tools … (fifteen with read_file and create_upload_link where storage is installed)". The unknown-argument sweep advertises fifteen tools (`toHaveLength(15)`), checked against the stub's real `tools/list`, which equals `VALID_ARGS` (15 keys, `delete_records` included). In `skill-md.ts` the import lines are a union (`MCP_OAUTH_SCOPE_DATA_WRITE` and `DELETE_RECORDS_MAX_PER_CALL`), and the tool list auto-merged with `delete_records` before the file tools' slot. `main` was merged a fourth time at `b93069ea` (main 5210acf, after #31061); only the tenant-audit census conflicted, resolved by taking `main`'s artefacts and regenerating. `main` was merged a fifth time at `da6a24cb`, after #31072 (a member's browser upload attaches through the commit door). Besides the census pair, three service-storage files conflicted and keep both sides: `file-attachment-service.ts` carries main's `attachUploadAs` and this PR's record-file verbs on one class; `registerStorageFamily` takes main's `attachUpload` sixth and this PR's `getUploadLinkDoor` seventh, and `composeStorageRoutes` passes both. - **The quota.** No byte quota exists in the open tree (measured in the section 1 report: zero hits in `service-storage`). An upload through a link meets the same `storage.max_upload_mb` gate before the link is spent, writes the same committed `sys_file` row with its size, and the same `storage.upload`, so whatever a host counts toward quota counts it. A byte quota is cloud's half; nothing here needs it. - **Strict single use.** A refusal before the spend (no session, another user, a rebound row, the size gate) leaves the link as it was. An upload refused after the spend (a field's `accept` or `maxSize`, revoked access) spends the link; the agent asks for a new one. The size gate runs before the spend so the common refusal does not. - **An ADR-0069 hold on the minting user is not re-checked at upload**; the family gate judges the uploading request's own session, which under the ruling is the minting user's. Grants and the organization claim are re-checked. - **The link path is the plugin's `basePath`.** A host that mounts the storage family under another base would need the link built from that base. - **`getWritableFields` is advisory at mint**; the write itself goes through the record's update door, which is the judgment that holds. - **Files beyond the claim's listed surface**: `packages/mcp/package.json` and `pnpm-lock.yaml` (the D1 dependency), `content/docs/api/plugin-endpoints.mdx` (the two routes), the two census pages and the counts ledger (gate-forced, above), and the route-ledger parity dogfood's comment. Inside `service-storage` beyond calling existing doors: the link routes, the page, and the conditional update in `metadata-store.ts`, all of them the one single-use upload-link record the claim allows. - **Direct protocol upload** stays out of scope. The link door (`UploadLinkDoor`) and the bound-target types are where an MCP-side upload would attach later. ## Verification **Head da6a24c** (merge of main 24bc259; install and a full declaration build, 119/119): the 13 MCP dogfood files 204/204 (`showcase-mcp-record-files` among them) plus `anonymous-door-sweep`, `authz-conformance`, `route-ledger-live-mount-parity` 88/88; `@objectstack/mcp` 60 files 762/762; `@objectstack/service-storage` 75 files 1424/1424; runtime bridge 5/5; console `objectstack-skill.test.ts` 13/13; typecheck on the three packages exit 0; `dispatch-gates --commands` on the merge delta (the five resolved files) 82 derived, 82 run, all exit 0, `--ran` 82 / 82 / 0 not measured. The tenant-audit census was regenerated (268 sites). **Head b93069e** (merge of main 5210acf, which carries #31061's attach fix; install and a full declaration build, 119/119): dogfood `showcase-mcp-record-files` 13/13, the three attachments-list cases green again; the 12 MCP dogfood files plus `anonymous-door-sweep`, `authz-conformance`, `route-ledger-live-mount-parity`: 16 files 292/292; `@objectstack/mcp` 60 files 762/762; `@objectstack/service-storage` 74 files 1396/1396; runtime bridge 5/5; console `objectstack-skill.test.ts` 13/13; typecheck on the three packages exit 0; `dispatch-gates --commands` on the merge delta 47 derived, 47 run, all exit 0. The tenant-audit census conflicted once more and was regenerated (267 sites). **Head c2a3229** (merge of main 5cb7d7e; install and a full workspace build with declarations, 119/119 tasks): `@objectstack/mcp` 59 files 746/746; `@objectstack/service-storage` 74 files 1389/1389; runtime `mcp-record-file-bridge.test.ts` 5/5; typecheck on the three packages exits 0; console `objectstack-skill.test.ts` 13/13 with no `ui/**` change. Dogfood `showcase-mcp-record-files` had 3 attachments-list cases red on this head, from main's #31031 (a member's `attachFileAs` refused on the `sys_file` lookup). They are green on `b93069ea` above, after #31061 landed the fix on `main`; this PR carries no rider. <details><summary>Head 57724d8: the owner check (contract review should-fix 1, 8d23d82) and the second main merge</summary> - `record-file-access.integration.test.ts`: 22 of 22. `@objectstack/service-storage` whole suite: 74 files, 1389 of 1389 (one more than `67847d9c`'s 1388: the new pin). - **Ablation** through `scripts/ablation-replace.mjs --delete` of `if (row.owner_id !== state.user_id) refuseUnusableLink();` (anchor 1 → 0, blob e63efe2 → 7625cc64): "a row whose binding names another user than its stamped uploader is no link" turned red, `AssertionError: expected 200 to be 404` (the rebound row opened for the other user), Tests 1 failed, 21 passed of 22. Restored: blob equals HEAD (e63efe2), `git diff HEAD` empty. - After the merge at `57724d86` (install, full workspace build with declarations, 119 of 119 tasks): `@objectstack/service-storage` 74 files, 1389 of 1389; `@objectstack/mcp` 58 files, 714 of 714; `@objectstack/runtime` `mcp-record-file-bridge.test.ts` 5 of 5; `typecheck` on the three packages and `tsc --noEmit` on the dogfood exit 0; dogfood `showcase-mcp-record-files`, `anonymous-door-sweep`, `authz-conformance`, `route-ledger-live-mount-parity` and `mcp-system-object-action-exposure`: 5 files, 114 of 114. - Derived gates on `57724d86` for this round's delta (the two `record-file-access` files and the two census artefacts): 84 derived, 84 run, every one exit 0; `dispatch-gates --ran` reconciles 84 / 84 / 0 not measured. (On `8d23d823`, before the merge, the 53 derived for the two code files also passed 53 of 53.) Two of them needed outputs first and are counted after: `check:dual-build-cjs-loads` and `check:i18n` exit 3 until a workspace build, and `check-plugin-teardown-shape --self-test` until its control commit is fetched into the shallow clone. </details> <details><summary>Earlier heads</summary> - **On `04ff76bc`**: the derived gates passed 111 of 111. `dispatch-gates --ran` reconciled them as 111 derived, 111 run, 0 not measured. One gate, `check-comment-mask-corpus`, failed with ENOENT the first time because it ran alongside the dogfood and read a temporary `objectstack.config.bundled_*.mjs`; rerun after the dogfood finished, it passed (21155 files, 0 disagree). The full dogfood passed: `os-verify-lock: VERDICT command-exit 0`, Test Files 426 passed and 3 skipped of 429, Tests 3556 passed and 41 skipped of 3597, 0 failed. That includes the anonymous-door sweep and the authz-conformance pin. - **On `c5fe7df9`** (after the merge) and `67847d9c`: `@objectstack/service-storage` 74 files and 1388 of 1388 tests; `@objectstack/mcp` 58 files and 711 of 711 tests (with #30861's); `@objectstack/runtime` `mcp-record-file-bridge.test.ts` 5 of 5; `tsc --noEmit` on all three packages and the dogfood exits 0; 111 of 111 derived gates; the full dogfood on the merge tree: Test Files 429 passed and 3 skipped of 432, Tests 3573 passed and 41 skipped of 3614, 0 failed. CI on `67847d9c`: 59 success, 7 skipped, 0 failed. - **Ablations** (on the service-storage integration suite): changing the spend guard `if (!spent)` to `if (spent < 0)` turns "of two concurrent uses exactly one lands" red (1 failed of 19 at the time); removing `deadline <= this.now()` turns the expiry test red. Both restored, blobs equal to HEAD. - Earlier heads `ebd0dbc4` and `e3f0aa4e` also passed 111 of 111 gates. On `bc5ad15b` (before B), the full dogfood had 2 failures: the authz pin (fixed in `bc5ad15b`) and the anonymous-door sweep, which led to the sign-in choice the maintainer ruled above. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` is not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran. `pnpm lint` is CI's run. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not applicable. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. <!-- signed: domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9c1edbc commit 704efef

30 files changed

Lines changed: 3978 additions & 84 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/mcp': patch
3+
'@objectstack/runtime': patch
4+
'@objectstack/service-storage': patch
5+
---
6+
7+
feat(mcp): an agent can read a record's files and attach a file to a record — `read_file` and `create_upload_link` over `/api/v1/mcp`, and the single-use upload link they hand out (#30892)
8+
9+
Clause-②: yes (widening) — two MCP tools join the HTTP surface where the storage service is installed (`read_file` under `data:read`, `create_upload_link` under `data:write`), and two signed-in routes join the storage family: `GET` and `PUT /api/v1/storage/upload-links/:token`. Nothing that answered before answers differently.
10+
Metadata-protocol: none
11+
12+
- **`read_file`** takes `objectName`, `recordId` and a file field (with `fileId` when the field holds several files) or an `attachmentId`. With neither, it lists the record's files. It answers the file's facts (id, name, type, size, the read cap that applies), a short-lived download link, and the content: a PDF's or Word document's text (extracted on the server, capped in pages and characters), an image as an MCP image block, a text file as text. A file over the cap (10 MiB) answers its facts and the download link, without content. Access follows the record: a record the caller cannot read answers `404 RECORD_NOT_FOUND`, as `get_record` does, and a file the download door would refuse answers that door's `403`.
13+
- **`create_upload_link`** takes a record the caller may edit plus a file field or `attachments: true`. It answers a link that accepts exactly one upload for ten minutes, bound to that record and that field (or the record's attachments), and usable only by the same user, signed in: a request without a session answers `401 AUTH_REQUIRED`, like every other storage door. The upload lands as the caller would land it: the file field is written through the record's own update door, so the field's `accept` and `maxSize`, the record's edit access and the upload size limit (`storage.max_upload_mb`, `413` before the link is spent) all apply. It writes the same `sys_file` row, size recorded, and the same `storage.upload` as any other upload, so whatever a host counts toward quota counts it. A used, expired or unknown link, or one made for another user, answers the same `404`.
14+
- **`@objectstack/mcp`** loads `unpdf` (pinned 1.9.1) by dynamic import on the first PDF read, with pdf.js eval off; a Word document is read with `node:zlib` under a bounded inflate.

‎content/docs/ai/connect-mcp.mdx‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,8 @@ decision.
245245

246246
## What the agent gets
247247

248-
Thirteen tools, generated from your metadata:
248+
Thirteen tools, generated from your metadata, and two more for files where the
249+
storage service is installed:
249250

250251
| Tool | What it does |
251252
|:---|:---|
@@ -257,6 +258,40 @@ Thirteen tools, generated from your metadata:
257258
| `delete_records` | Delete many records of one object, by a filter or an id list, at most 200 per call (an OAuth token needs both `data:read` and `data:write`) |
258259
| `list_actions` / `run_action` | Discover and invoke your business actions by name |
259260
| `resume_run` | Finish a flow action that paused on a screen, by submitting the screen's values |
261+
| `read_file` | Read a file a record carries: its text (text files, PDF, Word `.docx`) or the image itself, with its size, the byte cap and a short-lived download link |
262+
| `create_upload_link` | Hand the user a single-use link that uploads one file onto a record's file field or its attachments |
263+
264+
### Files: `read_file` and `create_upload_link`
265+
266+
Both tools are registered only where the storage service is installed (they
267+
read and write through it), `read_file` with the `data:read` scope and
268+
`create_upload_link` with `data:write`. Neither judges access on its own: the
269+
record is read first, exactly as `get_record` reads it, so **no access to the
270+
record means no file**, refused with the same `RECORD_NOT_FOUND`.
271+
272+
- **`read_file({ objectName, recordId, field?, fileId?, attachmentId? })`**
273+
reads one file: the one a file field holds (`field`, plus `fileId` when the
274+
field holds several), or one attachment (`attachmentId`). Send neither to
275+
list the record's files with their ids. The file is then judged by the same
276+
rule as a download from the Console. The answer carries the file's name,
277+
type and size, the byte cap `read_file` reads (10 MiB), a download link that
278+
expires after five minutes, and the content: the text of a text file, a PDF
279+
or a Word document (cut at 100,000 characters or 200 PDF pages), or the image
280+
itself for PNG, JPEG, GIF and WebP up to 5 MiB. A larger file answers its
281+
facts and the link only; a damaged PDF or Word file answers a sentence
282+
saying so.
283+
- **`create_upload_link({ objectName, recordId, field? | attachments: true })`**
284+
needs a record the caller may edit (and, for a field, a field they may
285+
write). It answers a link and its expiry (ten minutes). The user opens the
286+
link in a browser where they are signed in to this deployment, picks one
287+
file, and it lands on exactly that record and field, or as an attachment of
288+
that record. On a phone, they sign in there first. The link works only for
289+
the user it was made for, and only once: without a session it answers
290+
`401`, and a second use, an expired link, an unknown link and another user's
291+
link all get the same `404`. The upload is stored as that user, with their
292+
access checked again when the file arrives, and it meets the storage
293+
service's maximum upload size before the link is spent. Sending a file's
294+
bytes through the MCP protocol itself is not supported yet.
260295

261296
The tools are a fixed ~10-tool **spine** with the object name as a *parameter*
262297
(`query_records(objectName, …)`), not one tool per object — so the list stays
@@ -454,7 +489,7 @@ call '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":
454489
# → {"result":{"serverInfo":{"name":"objectstack",...},"capabilities":{"tools":{...},"prompts":{}}},...}
455490

456491
call '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | grep -o '"name":"[a-z_]*"'
457-
# → the thirteen tools, list_objects … resume_run
492+
# → the thirteen tools, list_objects … resume_run (fifteen with read_file and create_upload_link where storage is installed)
458493

459494
call '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"query_records","arguments":{"objectName":"crm_opportunity","limit":3}}}'
460495
# → {"result":{"content":[{"type":"text","text":"{ \"object\": \"crm_opportunity\", \"records\": [ ... ], \"total\": 23, \"hasMore\": true }"}]}}

‎content/docs/api/plugin-endpoints.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,8 @@ other adapter.
161161
| GET | `/storage/upload/chunked/:uploadId/progress` | `storage.resumeUpload` | Read session progress (first step of a resume) |
162162
| GET | `/storage/files/:fileId/url` | `storage.getDownloadUrl` | Resolve a short-lived signed download URL |
163163
| GET | `/storage/files/:fileId` | — | Stable browser URL; 302s to the same signed URL (what file fields carry) |
164+
| GET | `/storage/upload-links/:token` | — | The page a single-use upload link opens (minted by the MCP `create_upload_link` tool) for the signed-in user it was made for: a file picker |
165+
| PUT | `/storage/upload-links/:token` | — | Spend the link on the request body, as that signed-in user; the file lands on the record and field the link is bound to |
164166

165167
`storage.upload(file)` runs steps 1–3 for you, including the direct-to-storage
166168
`PUT` in step 2. Step 1 refuses an empty `mimeType` with `400 INVALID_REQUEST`,

‎content/docs/permissions/system-context.mdx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ One of the largest consumers of the flag;
186186
| 47b | The inbound-mail ingest seam: `ingest` admits the system context ALONE, and `rematch` admits it without the organization, owner or `manage_mailboxes` check | service-mailbox | Get: a mail source hands a mailbox its messages, and server code re-matches any mailbox. Lose: nothing a user held — every other caller of `ingest` is refused (`PERMISSION_DENIED`), because an ingested message is shown on every record its correspondents' addresses match and a user who could hand the seam a message could write one in a correspondent's name; every other caller of `rematch` must be in the mailbox's organization and own the mailbox or hold `manage_mailboxes` | `ingest`, `authorizeRematch` |
187187
| 47c | The inbound-mail IMAP sync: `syncMailbox` and `syncAll` admit the system context ALONE | service-mailbox | Get: the sync job reads every mailbox row (its organization, address and IMAP cursor), writes the cursor back, and hands each fetched message to `ingest` under the system context. Lose: nothing a user held — every other caller is refused (`PERMISSION_DENIED`), because a sync stores a mailbox's mail and moves its cursor | `syncMailbox`, `syncAll` |
188188
| 47d | The print service REFUSES the system context: `printRecordToFile` admits a human user acting for themselves alone | service-print | Get: nothing — a system caller is refused (`PERMISSION_DENIED`) before any page is read, session minted or browser started. Lose: nothing a user held — the PDF is rendered as the requesting user and never as the system, so a record is archived only by someone who could read it | `refuseUnlessHumanCaller` |
189-
| 47e | The caller-scoped attach verbs REFUSE the system context: `attachFileAs` and `assertMayAttach` admit a signed-in user alone | service-storage | Get: nothing — a system caller is refused (`PERMISSION_DENIED`); the system attaches through `attachFile`, whose rows name no user. Lose: nothing a user held — the caller-scoped verbs insert the attachment under the caller's context so every insert gate judges that user and both rows name them | `requireUserContext` |
189+
| 47e | The caller-scoped attach verbs REFUSE the system context: `attachFileAs` and `assertMayAttach` admit a signed-in user alone, and so do the caller-scoped record-file verbs the MCP `read_file` and `create_upload_link` tools call (`listRecordFilesAs`, `readRecordFileAs`, `createUploadLinkAs`) | service-storage | Get: nothing — a system caller is refused (`PERMISSION_DENIED`); the system attaches through `attachFile`, whose rows name no user. Lose: nothing a user held — the caller-scoped verbs insert the attachment under the caller's context so every insert gate judges that user and both rows name them, and the record-file verbs read the record and judge the file as that user, and bind an upload link to them | `packages/services/service-storage/src/file-attachment-service.ts#requireUserContext`, `packages/services/service-storage/src/record-file-access.ts#requireUserContext` |
190190
| 47f | The file-reference claim path asks no read verdict of a system write: a record write claims or copies a referenced file only as its caller may read it (the download door's verdict), and a system write carries no caller to ask | service-storage | Get: a system write claims or copies any file the organization-scoped lookup reaches, as before #25692; a deliberate server-side copy between records runs this way. Lose: nothing a user held — a user write is judged by the download door's verdict, and a context-less programmatic write is not asked either | `referencingCallerOf` |
191191
| 47g | The `sys_file` storage-owned columns guard returns early on create: a system insert keeps the `key`, `mime_type`, `size`, `status` and `etag` it writes | service-storage | Get: the storage service's own inserts (the upload doors, the field-file copy and backfill) write those five columns. Lose: nothing a user held — a non-system create has them dropped and starts `pending`, and on update the same columns are `readonly`, so the UPDATE `readonly` strip keeps them for every non-system caller | `installSysFileStorageOwnedColumnsGuard` |
192192

@@ -679,6 +679,7 @@ and CI refuses one that differs from what the tree produces.
679679
| `packages/services/service-storage/src/attachment-access-hooks.ts#installAttachmentReadVisibility` | 1 | 44 |
680680
| `packages/services/service-storage/src/file-attachment-service.ts#requireUserContext` | 1 | 47e |
681681
| `packages/services/service-storage/src/file-reference-lifecycle.ts#referencingCallerOf` | 1 | 47f |
682+
| `packages/services/service-storage/src/record-file-access.ts#requireUserContext` | 1 | 47e |
682683
| `packages/services/service-storage/src/sys-file-storage-owned-columns.ts#installSysFileStorageOwnedColumnsGuard` | 1 | 47g |
683684

684685
{/* END GENERATED: system-context-census */}

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 28 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ what moved this page's population from 225 to 227; nothing about the two sites
9393
changed, only whether this instrument could see them.
9494

9595
**The expensive failure direction is a keyword.** Sites whose receiver the author
96-
typed `any` have no type to read, and there are 63 of them — just over a fifth
96+
typed `any` have no type to read, and there are 64 of them — just over a fifth
9797
of the population, concentrated in exactly the seed and bootstrap paths this
9898
control exists for. Scoring an unreadable receiver as "not an engine" would have
9999
dropped every one of them silently, with a clean exit and a smaller number that
@@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way.
132132

133133
The same holds twice over for the context. An options argument spelled as a
134134
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
135-
forwarding shim cannot, and **55 of the 266 sites are spelled that way**. A
135+
forwarding shim cannot, and **56 of the 268 sites are spelled that way**. A
136136
context resolved from an inline literal or a local `const` can be tested for
137137
`isSystem`; one arriving from a helper call cannot.
138138

@@ -160,8 +160,8 @@ now **0**: nothing on this surface threads a context that provably lacks the fla
160160

161161
**"No tenant context" counted sites it had not read.** An options argument the
162162
walker could not parse was folded into the same bucket as one it had read and
163-
found empty. That published **63 sites "carrying no tenant context at all"**
164-
when 8 said so and 55 were simply unread — an over-claim in the *alarming*
163+
found empty. That published **64 sites "carrying no tenant context at all"**
164+
when 8 said so and 56 were simply unread — an over-claim in the *alarming*
165165
direction, on the very figure this page tells other cards to cite. `carries` is
166166
now three-valued, and an unreadable argument can never contribute to the
167167
provable count.
@@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
197197

198198
| carried figure | where it survives | this census |
199199
| :--- | :--- | ---: |
200-
| 175 write call sites | quoted in the merged changeset | **266** |
201-
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable |
202-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **189 of 266** decidable, **77** undecidable |
203-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 148 decidably elevated, 0 decidably not, 110 undecidable |
200+
| 175 write call sites | quoted in the merged changeset | **268** |
201+
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **30** more whose options argument is unreadable |
202+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **190 of 268** decidable, **78** undecidable |
203+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 148 decidably elevated, 0 decidably not, 112 undecidable |
204204
| 141 and 132, two independent re-derivations | the card that filed this work | — |
205205

206206
**The differences are not reconciled, and deliberately so.** The old census's
@@ -210,21 +210,21 @@ be stated is what this instrument counts, which is written above and re-runnable
210210
at any commit.
211211

212212
Two structural facts do plausibly widen this reading against any hand or regex
213-
one, and both are counted in the generated tables below: the 63 sites reached
213+
one, and both are counted in the generated tables below: the 64 sites reached
214214
through an erased (`any`) receiver, and the 69 that name their object through a
215215
`const` rather than inline. An instrument that read either the way a person does
216216
would report a smaller number and would not say so.
217217

218218
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
219219
figure has no surviving corroboration anywhere in the tree.** This census reads
220-
148 of 266 (56%) as decidably elevated, with 110 more whose elevation is a
220+
148 of 268 (55%) as decidably elevated, with 112 more whose elevation is a
221221
run-time fact — so the claim is neither confirmed nor refuted, and the honest
222222
answer is that a static reading cannot settle it.
223223

224-
⇒ **Cite `2 / 266`, and say what it is**: the sites whose options argument was
224+
⇒ **Cite `2 / 268`, and say what it is**: the sites whose options argument was
225225
READ and holds no tenant context, against a decidably tenancy-enabled object.
226226
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
227-
without tenant context" — **29 further sites** have an options argument this
227+
without tenant context" — **30 further sites** have an options argument this
228228
cannot read, and they are neither in nor out.
229229

230230
{/* BEGIN GENERATED: tenant-audit-census (scripts/tenant-audit-census.mjs) — DO NOT EDIT */}
@@ -233,31 +233,31 @@ cannot read, and they are neither in nor out.
233233

234234
| what | count |
235235
| :--- | ---: |
236-
| write call sites on the application surface | **266** |
237-
| …whose object name is statically decidable | 189 |
238-
| …whose object name is chosen at run time | 77 |
239-
| …against an object with tenancy ENABLED | 183 |
236+
| write call sites on the application surface | **268** |
237+
| …whose object name is statically decidable | 190 |
238+
| …whose object name is chosen at run time | 78 |
239+
| …against an object with tenancy ENABLED | 184 |
240240
| …against an object that declares tenancy off | 6 |
241-
| threading a tenant context | 203 |
241+
| threading a tenant context | 204 |
242242
| PROVABLY carrying none (options read, no context key) | **8** |
243243
| …of those, against a decidably tenancy-enabled object | **2** |
244-
| options argument UNREADABLE — may or may not carry one | 55 |
245-
| …of those, against a decidably tenancy-enabled object | 29 |
244+
| options argument UNREADABLE — may or may not carry one | 56 |
245+
| …of those, against a decidably tenancy-enabled object | 30 |
246246
| threading a decidably ELEVATED (`isSystem`) context | 148 |
247247
| threading a context that is decidably NOT elevated | 0 |
248-
| threading a context whose elevation is a run-time fact | 110 |
248+
| threading a context whose elevation is a run-time fact | 112 |
249249

250250
| how the instrument reached the site | count |
251251
| :--- | ---: |
252-
| receiver carried a readable engine type | 203 |
253-
| receiver erased, placed by the object NAME | 53 |
252+
| receiver carried a readable engine type | 204 |
253+
| receiver erased, placed by the object NAME | 54 |
254254
| receiver erased, placed by an `object: string` PARAMETER | 5 |
255255
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
256256

257-
| object name spelled inline | 120 |
257+
| object name spelled inline | 121 |
258258
| object name spelled through a `const` | 69 |
259259
| object name is an `object: string` parameter | 7 |
260-
| object name is some other run-time expression | 70 |
260+
| object name is some other run-time expression | 71 |
261261

262262
### Subtractions the census could NOT defend — enforced
263263

@@ -307,13 +307,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
307307
their values are not compared. The reasoning, and the measurement behind it,
308308
are in `scripts/check-tenant-audit-census.mjs`.
309309

310-
Measured on 2026-11-07 at `ce0325138`.
310+
Measured on 2026-11-07 at `b93069eab`.
311311

312312
| corpus scale (not enforced) | count |
313313
| :--- | ---: |
314-
| tracked non-test sources scanned | 1045 |
315-
| engine-shaped types recognised | 112 |
314+
| tracked non-test sources scanned | 1049 |
315+
| engine-shaped types recognised | 113 |
316316
| declared objects in the registry | 130 |
317-
| same-named calls subtracted as non-engine | 234 |
317+
| same-named calls subtracted as non-engine | 236 |
318318

319319
{/* END GENERATED: tenant-audit-census */}

0 commit comments

Comments
 (0)