Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/32747-upload-link-multiple-files.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/service-storage': patch
'@objectstack/mcp': patch
'@objectstack/runtime': patch
---

feat(storage,mcp): one upload link takes several files on a multi-file field

Clause-②: yes (widening)
Metadata-protocol: none

A link `create_upload_link` makes for a file field declared `multiple: true` now takes several files in its one use: the user picks up to 10 on the link's page and sends them together, and each file gets its own file id. "Here are my three invoices" is one link and one pick instead of three. A single-file field link and an attachments link take one file, as before.

On a multi-file field's link, one send spends the link once, and every file in it, however many the send carries, is checked before that: the workspace's maximum upload size, and the field's own `accept` and `maxSize`. One file the field cannot take, or more than 10 files (`400` `VALIDATION_ERROR`), refuses the whole send, stores nothing and leaves the link usable. After that the field gains all the files in one write, or none: a failure storing any of them, or the record's write refusing (a pending approval's lock, say), discards every file of the send, so the record shows what it held before and no file of the send is left.

For a record not yet created, `create_upload_link` takes `count` (how many files, up to 10) with a multi-file field, and answers that many `fileIds` at once; the user picks exactly that many, and the agent creates the record with the field set to `fileIds`. Each id keeps the rules a single upload's id has: only that user's own write, into that field of that object, in one record, within an hour of the upload. Before the upload, every write naming one of the ids is refused, the uploader's own included, and nothing serves it.

The link's page takes several files where the link does, in every language it is served in. The send is one form (`multipart/form-data`, marked by the header `x-upload-link-form: several`) carrying `count` and `file0` … `fileN-1`, and only a link that takes several files reads it. Any other body, on any link (a multipart one without that header included), is the file itself, whatever its content type, under the same limit and with the same checks as before. Every successful answer now also carries `fileIds`.
19 changes: 15 additions & 4 deletions content/docs/ai/connect-mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ storage service is installed:
| `list_actions` / `run_action` | Discover and invoke your business actions by name |
| `resume_run` | Finish a flow action that paused on a screen, by submitting the screen's values |
| `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 |
| `create_upload_link` | Hand the user a single-use link that uploads one file onto a record's file field or its attachments |
| `create_upload_link` | Hand the user a single-use link that uploads a file onto a record's file field or its attachments (up to 10 files, sent together, for a multi-file field) |

### Files: `read_file` and `create_upload_link`

Expand All @@ -301,7 +301,15 @@ record means no file**, refused with the same `RECORD_NOT_FOUND`.
write). It answers a link and its expiry (ten minutes). The user opens the
link in a browser where they are signed in to this deployment, picks one
file, and it lands on exactly that record and field, or as an attachment of
that record. On a phone, they sign in there first. The link works only for
that record. On a phone, they sign in there first. A link for a
`multiple: true` file field takes several files in its one use: the user
picks up to 10 and sends them together, every file of the send, however
many, is checked (the maximum upload size, and the field's own `accept` and
`maxSize`) before the link is spent, and the field gains all of them in one write, or none: one file the
field cannot take, or more than 10 (`400` `VALIDATION_ERROR`), refuses the
whole send with the link still usable, and a failure after that leaves the
record as it was. A single-file field link and an attachments link take one
file. The link works only for
the user it was made for, and only once: without a session it answers
`401`, and a second use, an expired link, an unknown link and another user's
link all get the same `404`. The upload is stored as that user, with their
Expand All @@ -326,10 +334,13 @@ record means no file**, refused with the same `RECORD_NOT_FOUND`.
`recordId`. That needs a user who may create records of the object and
write the field, and a field that is not `readonly`; an attachments link
still needs its record. The answer carries `fileIds`, the id the upload is
stored under. Once the user has uploaded (the page says the file was
stored under. For several files into a multi-file field, the agent sends
`count` (up to 10): `fileIds` then holds that many ids, and the user must
pick exactly that many. Once the user has uploaded (the page says the file was
received), the agent calls `create_record` with the field set to
`fileIds[0]`, or the array for a multi-file field; before the upload that
create is refused as an upload that never completed. The field's own
create is refused as an upload that never completed, as is every other
write naming one of the ids. The field's own
`accept` and `maxSize` are checked when the file arrives, before the link is
spent. Until a record holds the file, the id works only for that user's own
`create_record` or `update_record` into that field of that object, within
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ One of the largest consumers of the flag;
| 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` |
| 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` |
| 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`, `createUploadLinkForNewRecordAs`) | 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; `createUploadLinkForNewRecordAs` names no record, so it asks the object's create verdict and the field's writability as that user instead | `packages/services/service-storage/src/file-attachment-service.ts#requireUserContext`, `packages/services/service-storage/src/record-file-access.ts#requireUserContext` |
| 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. The exception is a file uploaded through an upload link that names no record (#32686), and the id such a link answers before its upload: until a record has held the file, only its uploader's own write takes it, so a system write naming it is refused at the field. Once a record holds it, a system write copies it as any; once a record lets it go, it is judged as any released file. 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` |
| 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. The exception is a file uploaded through an upload link that names no record (#32686), and the ids such a link answers before its upload (its own, and those it reserves for a several-file upload): until a record has held the file, only its uploader's own write takes it, so a system write naming it is refused at the field. Once a record holds it, a system write copies it as any; once a record lets it go, it is judged as any released file. 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` |
| 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` |

### 5. Actions, metadata plane, provenance, the organization wall
Expand Down
4 changes: 3 additions & 1 deletion packages/mcp/src/file-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,8 +345,10 @@ export interface McpFileBridge {
* [#32686] A single-use upload link for a file field of a record the caller
* is about to create. Optional on its own: a host without it serves links
* for existing records only, and `create_upload_link` then needs `recordId`.
* [#32747] `count`: how many files the user will upload through it (a
* multi-file field), answered as that many `fileIds`.
*/
createUploadLinkForNewRecord?(object: string, target: { field: string }): Promise<McpNewRecordUploadLink>;
createUploadLinkForNewRecord?(object: string, target: { field: string; count?: number }): Promise<McpNewRecordUploadLink>;
}

/** One MCP tool-result content block `read_file` answers with. */
Expand Down
66 changes: 66 additions & 0 deletions packages/mcp/src/mcp-file-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,72 @@ describe('[#32686] create_upload_link for a record not yet created', () => {
});
});

describe('[#32747] create_upload_link: one link, several files', () => {
let runtime: MCPServerRuntime;
beforeEach(() => {
runtime = new MCPServerRuntime({ name: 'file-tools', version: '0.0.0' });
});

/** A new-record bridge answering as many ids as `count` asks for. */
function countingBridge() {
const bridge = newRecordBridge();
Object.assign(bridge, {
async createUploadLinkForNewRecord(object: string, target: { field: string; count?: number }) {
bridge.calls.push(['createUploadLinkForNewRecord', object, target]);
return {
url: 'https://acme.test/api/v1/storage/upload-links/f1.sec',
expiresAt: '2026-11-13T12:10:00.000Z',
fileIds: Array.from({ length: target.count ?? 1 }, (_, i) => `f${i + 1}`),
};
},
} satisfies McpFileBridge);
return bridge;
}

it('count passes through to the bridge and the answer carries every id, with instructions to pick them all', async () => {
const bridge = countingBridge();
const result = await callTool(runtime, bridge, 'create_upload_link', { objectName: 'claim', field: 'scans', count: 3 });
expect(result.isError).toBeFalsy();
expect(bridge.calls).toEqual([['createUploadLinkForNewRecord', 'claim', { field: 'scans', count: 3 }]]);
const facts = factsOf(result);
expect(facts.fileIds).toEqual(['f1', 'f2', 'f3']);
expect(facts.instructions).toContain('pick all 3 files');
expect(facts.instructions).toContain('set to fileIds (all 3)');
});

it('no count asks the bridge as before', async () => {
const bridge = countingBridge();
await callTool(runtime, bridge, 'create_upload_link', { objectName: 'claim', field: 'scans' });
expect(bridge.calls).toEqual([['createUploadLinkForNewRecord', 'claim', { field: 'scans' }]]);
});

it('count with a recordId is refused before the bridge is asked', async () => {
const bridge = countingBridge();
const result = await callTool(runtime, bridge, 'create_upload_link', { objectName: 'claim', recordId: 'c1', field: 'scans', count: 2 });
expect(result.isError).toBe(true);
expect(textOf(result.content[0])).toContain('count is for a record not yet created');
expect(bridge.calls).toEqual([]);
});

it('count is listed only where a link can name no record, and the description says a multi-file link takes several', async () => {
const served = await uploadLinkSchema(runtime, countingBridge());
expect(Object.keys(served.inputSchema.properties)).toContain('count');
// The storage door's cap (UPLOAD_LINK_MAX_FILES), declared where the agent reads it.
expect(served.inputSchema.properties.count).toMatchObject({ type: 'integer', minimum: 1, maximum: 10 });
expect(served.description).toContain('takes several files, up to 10');
const notServed = await uploadLinkSchema(runtime, fullBridge());
expect(Object.keys(notServed.inputSchema.properties)).not.toContain('count');
expect(notServed.description).not.toContain('uploads one file');
});

it('the skill teaches several files and count', () => {
const skill = renderSkillMarkdown({ fileTools: true, newRecordUploadLinks: true });
expect(skill).toContain('takes up to 10 files');
expect(skill).toContain('send\n `count` (up to 10)');
expect(renderSkillMarkdown({ fileTools: true })).not.toContain('`count`');
});
});

describe('the skill teaches the file tools exactly where they register', () => {
it('names both for a host that carries them, and each only for the scope that registers it', () => {
const all = renderSkillMarkdown({ fileTools: true });
Expand Down
Loading
Loading