Skip to content

Commit ebd0dbc

Browse files
committed
test(mcp,storage): pin read_file and create_upload_link on a booted showcase, docs and changeset (#30892)
The dogfood file drives both tools over /api/v1/mcp as a member and the admin on the showcase's invoice graph (owner RLS, controlled_by_parent, a receipt file field, enable.files attachments), spends links through the real PUT route, and pins access, the binding, single use, expiry and the upload limit. Docs list the two tools and the two routes; one changeset for the three published packages. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4cyqd3A7fbCWrqM3KCz
1 parent 75bc8cf commit ebd0dbc

7 files changed

Lines changed: 535 additions & 7 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 public 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). 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 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: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,8 @@ decision.
244244

245245
## What the agent gets
246246

247-
Twelve tools, generated from your metadata:
247+
Twelve tools, generated from your metadata, and two more for files where the
248+
storage service is installed:
248249

249250
| Tool | What it does |
250251
|:---|:---|
@@ -255,6 +256,38 @@ Twelve tools, generated from your metadata:
255256
| `create_record` / `update_record` / `delete_record` | Write data |
256257
| `list_actions` / `run_action` | Discover and invoke your business actions by name |
257258
| `resume_run` | Finish a flow action that paused on a screen, by submitting the screen's values |
259+
| `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 |
260+
| `create_upload_link` | Hand the user a single-use link that uploads one file onto a record's file field or its attachments |
261+
262+
### Files: `read_file` and `create_upload_link`
263+
264+
Both tools are registered only where the storage service is installed (they
265+
read and write through it), `read_file` with the `data:read` scope and
266+
`create_upload_link` with `data:write`. Neither judges access on its own: the
267+
record is read first, exactly as `get_record` reads it, so **no access to the
268+
record means no file**, refused with the same `RECORD_NOT_FOUND`.
269+
270+
- **`read_file({ objectName, recordId, field?, fileId?, attachmentId? })`**
271+
reads one file: the one a file field holds (`field`, plus `fileId` when the
272+
field holds several), or one attachment (`attachmentId`). Send neither to
273+
list the record's files with their ids. The file is then judged by the same
274+
rule as a download from the Console. The answer carries the file's name,
275+
type and size, the byte cap `read_file` reads (10 MiB), a download link that
276+
expires after five minutes, and the content: the text of a text file, a PDF
277+
or a Word document (cut at 100,000 characters or 200 PDF pages), or the image
278+
itself for PNG, JPEG, GIF and WebP up to 5 MiB. A larger file answers its
279+
facts and the link only; a damaged PDF or Word file answers a sentence
280+
saying so.
281+
- **`create_upload_link({ objectName, recordId, field? | attachments: true })`**
282+
needs a record the caller may edit (and, for a field, a field they may
283+
write). It answers a link and its expiry (ten minutes). The user opens the
284+
link in a browser, on a phone too, picks one file, and it lands on exactly
285+
that record and field, or as an attachment of that record. The link works
286+
once: a second use, an expired link and an unknown link all get the same
287+
answer. The upload is stored as the user who asked for the link, with their
288+
access checked again when the file arrives, and it meets the storage
289+
service's maximum upload size before the link is spent. Sending a file's
290+
bytes through the MCP protocol itself is not supported yet.
258291

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

405438
call '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | grep -o '"name":"[a-z_]*"'
406-
# → the twelve tools, list_objects … resume_run
439+
# → the twelve tools, list_objects … resume_run (fourteen with read_file and create_upload_link where storage is installed)
407440

408441
call '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"query_records","arguments":{"objectName":"crm_opportunity","limit":3}}}'
409442
# → {"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): a file picker |
165+
| PUT | `/storage/upload-links/:token` | — | Spend the link on the request body; 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`,

‎packages/mcp/src/mcp-http-tools.unknown-argument-keys.test.ts‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,9 @@ import { describe, it, expect, beforeEach } from 'vitest';
4444

4545
import { MCPServerRuntime } from './mcp-server-runtime.js';
4646
import type { McpActionBridge, McpDataBridge } from './mcp-http-tools.js';
47+
import type { McpFileBridge } from './file-content.js';
4748

48-
interface StubBridge extends McpDataBridge, McpActionBridge {
49+
interface StubBridge extends McpDataBridge, McpActionBridge, McpFileBridge {
4950
calls: any[];
5051
}
5152

@@ -64,6 +65,16 @@ function makeBridge(): StubBridge {
6465
async listActions() { calls.push(['listActions']); return [{ name: 'complete_task', objectName: 'crm_opportunity' }]; },
6566
async runAction(name: string, input: any) { calls.push(['runAction', name, input]); return { ok: true }; },
6667
async resumeRun(runId: string, input: any) { calls.push(['resumeRun', runId, input]); return { ok: true }; },
68+
// [#30892] The file pair's members, so `read_file` / `create_upload_link` are swept too.
69+
async listRecordFiles(object: string, recordId: string) { calls.push(['listRecordFiles', object, recordId]); return []; },
70+
async readRecordFile(object: string, recordId: string, target: any, options: any) {
71+
calls.push(['readRecordFile', object, recordId, target, options]);
72+
return { fileId: 'f1', name: 'a.txt', mimeType: 'text/plain', size: 1, maxBytes: options.maxBytes, bytes: new Uint8Array([0x61]), download: null };
73+
},
74+
async createUploadLink(object: string, recordId: string, target: any) {
75+
calls.push(['createUploadLink', object, recordId, target]);
76+
return { url: 'https://x.test/api/v1/storage/upload-links/t', expiresAt: '2026-11-06T12:10:00.000Z' };
77+
},
6778
};
6879
}
6980

@@ -110,6 +121,8 @@ const VALID_ARGS: Record<string, Record<string, unknown>> = {
110121
list_actions: {},
111122
run_action: { actionName: 'complete_task' },
112123
resume_run: { runId: 'run_1' },
124+
read_file: { objectName: 'crm_opportunity', recordId: 'r1', field: 'contract' },
125+
create_upload_link: { objectName: 'crm_opportunity', recordId: 'r1', field: 'contract' },
113126
};
114127

115128
describe('MCP tool arguments — undeclared keys are refused (#16913)', () => {
@@ -213,12 +226,14 @@ describe('MCP tool arguments — undeclared keys are refused (#16913)', () => {
213226

214227
// ── The sweep: every advertised tool holds the same posture ────────────────
215228

216-
it('advertises the twelve tools this sweep covers', async () => {
229+
it('advertises the fourteen tools this sweep covers', async () => {
217230
const json = await rpc(runtime, bridge, 'tools/list');
218231
const names = (json.result?.tools ?? []).map((t: any) => t.name).sort();
219232
expect(names).toEqual(Object.keys(VALID_ARGS).sort());
220-
// [#15705] Twelve with `resume_run`, which this stub's bridge implements.
221-
expect(names).toHaveLength(12);
233+
// [#15705] Twelve with `resume_run`, which this stub's bridge implements;
234+
// [#30892] fourteen with `read_file` and `create_upload_link`, which it
235+
// implements too.
236+
expect(names).toHaveLength(14);
222237
});
223238

224239
it('every advertised tool ACCEPTS its declared arguments (the sweep control)', async () => {

‎packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ describe('route ledger ↔ live mount parity (#7526)', () => {
210210
stack = await bootShowcase({
211211
// Boot as WIDE as this gate can, so as few rows as possible have to be
212212
// pinned: every plugin added here converts a pin into a measurement.
213-
// `StorageServicePlugin` alone converts ten — the whole storage ledger,
213+
// `StorageServicePlugin` alone converts twelve — the whole storage ledger,
214214
// which the lean default boot leaves entirely unobserved.
215215
//
216216
// NOT `automation: true`, and the reason is worth writing down: the

0 commit comments

Comments
 (0)