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
9 changes: 9 additions & 0 deletions .changeset/32783-upload-link-description-and-relay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@objectstack/mcp": patch
"@objectstack/service-storage": patch
---

Clause-②: no
Metadata-protocol: none

`create_upload_link`'s description now says which link each refusal applies to: a field link gets no link for a read-only field (`readonly`, or a `readonlyWhen` that holds on the record now) or for a record a pending approval locks, while an attachments link asks only what attaching asks, so a locked record still takes attachments. The upload-link page and upload doors now answer a refusal's `error.code` from the closed ADR-0112 vocabulary: a producer's unregistered spelling is answered as the code its status derives, with the spelling beside it as `declaredCode`, as the upload commit door already answers it.
10 changes: 7 additions & 3 deletions content/docs/ai/connect-mcp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -313,9 +313,13 @@ record means no file**, refused with the same `RECORD_NOT_FOUND`.
strict refusal (`strictReadonlyWrites`) uses for it; `update_record` reports
the same lock under `droppedFields` with reason `readonly_when`), and a
record a pending approval locks (`409`
`RECORD_LOCKED`). A field that becomes read-only after the link was made
refuses the upload when it arrives, attaching nothing. Sending a file's
bytes through the MCP protocol itself is not supported yet.
`RECORD_LOCKED`). Those are a field link's refusals: an attachments link
asks only what attaching asks (the permission to create attachments and a
record the caller may edit), so a record a pending approval locks still
takes attachments. A field that becomes read-only, or a record that becomes
locked, after the link was made refuses the upload when it arrives,
attaching nothing. Sending a file's bytes through the MCP protocol itself
is not supported yet.
- **A record not created yet.** A record whose file field is `required` (an
expense line and its invoice, say) cannot be created before the file
exists, so `create_upload_link` also takes `objectName` and `field` with no
Expand Down
13 changes: 8 additions & 5 deletions packages/mcp/src/mcp-http-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3379,11 +3379,14 @@ export function registerFileTools(
description:
'Create a link through which the user uploads one file onto a record: into a file field (field) or ' +
'as an attachment (attachments: true). Give the link to the user to open in a browser where they are ' +
'signed in to this deployment as themselves; it works only for them, once, for this record and field ' +
'only, and expires (see expiresAt). The upload is stored as the user, so they must be able to edit the ' +
'record (and the field): a record a pending approval locks, a read-only field, or a field read-only on ' +
'this record in its current state (readonlyWhen), gets no link, and an ' +
'upload the field cannot take when it arrives is refused with the reason, attaching nothing.' +
'signed in to this deployment as themselves; it works only for them, once, for this record and this ' +
'field (or its attachments) only, and expires (see expiresAt). The upload is stored as the user, so they ' +
'must be able to edit the record. A field link also needs a field they may write, and gets no link for ' +
'a field declared read-only (readonly), a field read-only on this record in its current state ' +
'(readonlyWhen), or a record a pending approval locks. An attachments link asks only what attaching ' +
'asks, so a record a pending approval locks still takes attachments. An upload refused when it arrives ' +
'(for a field link: the field became read-only, or the record was locked, after the link was made) ' +
'answers the reason and attaches nothing.' +
(newRecordLinkFn
? ' For a record you have not created yet (a required file field), omit recordId and name the field: ' +
'the user must be able to create such records, and the answer carries fileIds, the id(s) the upload ' +
Expand Down
33 changes: 25 additions & 8 deletions packages/services/service-storage/src/storage-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2359,10 +2359,19 @@ function isUnusableUploadLink(err: unknown): boolean {
* so a fault's own text never reaches the link's holder — every other throw
* is the route's `500`.
*
* [#32734] The declared status is read over both spellings producers use
* (`resolveThrownHttpError`'s `declaredStatus`: `status`, then `statusCode`):
* a lifecycle hook's refusal declares `statusCode` — the record lock's
* `409 RECORD_LOCKED` — and reading `status` alone answered it `500`.
* [#32734] The declared status is `resolveThrownHttpError`'s
* `declaredStatus`, which reads three limbs in order: `status`; then
* `statusCode`, the spelling a lifecycle hook's refusal declares (the record
* lock's `409 RECORD_LOCKED`, which reading `status` alone answered `500`);
* then the validation shape (`code: 'VALIDATION_FAILED'` or a
* `ValidationError`), which declares `400`. A throw carrying no string `code`
* is not relayed, whatever it declares.
*
* [#32783] The code is the resolver's closed `code` (ADR-0112, the #9106
* ruling: `error.code` is a closed vocabulary at every door), as
* {@link relayAttachFailure} answers it: a registered spelling verbatim, and
* an unregistered one as the code its status derives, with the producer's
* spelling beside it as `declaredCode` ({@link demotedDeclaredCode}).
*
* [#32712] The page shows the refusal as `userMessage`, composed in the
* request's language (`copy`): the producer's own user-facing text when it
Expand All @@ -2380,18 +2389,26 @@ function relayUploadLinkRefusal(res: IHttpResponse, err: unknown, copy: UploadLi
}
const e = err as { code?: unknown; message?: unknown } | null;
if (!e || typeof e.code !== 'string') return false;
const status = resolveThrownHttpError(err).declaredStatus;
const thrown = resolveThrownHttpError(err);
const status = thrown.declaredStatus;
if (status === undefined) return false;
const relayable = (status >= 400 && status < 500) || (status === 503 && e.code === 'SERVICE_UNAVAILABLE');
// The outage arm names its code as spelled: a 503 whose spelling is not
// `SERVICE_UNAVAILABLE` stays a fault, though its status derives that code.
const relayable = (status >= 400 && status < 500) || (status === 503 && thrown.declaredCode === 'SERVICE_UNAVAILABLE');
if (!relayable) return false;
// [#32712] The producer's own code prefix (`RECORD_LOCKED: …`) is off the
// text, as the `/api/v1/data` door strips it: `code` carries the token.
const stripped = typeof e.message === 'string' ? withoutDeclaredCodePrefix(e.message, err) : '';
const message = stripped.length > 0 ? stripped : 'Upload refused';
// [#32712] What the page shows: a text the producer marked for the user,
// verbatim; else the page's own sentence for the code, in its language.
const userMessage = declaredUserMessage(err) ?? uploadLinkRefusalText(copy, status, e.code, message);
sendError(res, status, e.code as ErrorCode, message, { userMessage });
const userMessage = declaredUserMessage(err) ?? uploadLinkRefusalText(copy, status, thrown.code, message);
// [#32783] The closed code, and an unregistered spelling beside it.
const declaredCode = demotedDeclaredCode(thrown);
sendError(res, status, thrown.code, message, {
userMessage,
...(declaredCode !== undefined ? { declaredCode } : {}),
});
return true;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The upload-link doors' refusal relay (`relayUploadLinkRefusal`): which
* thrown refusals it answers, and with which `code`.
*
* - **The code is closed.** ADR-0112 and the #9106 ruling make `error.code` a
* closed vocabulary at every door. A refusal whose spelling is registered
* keeps it; one whose spelling is not answers the code its status derives,
* with the spelling beside it as `declaredCode` — the answer the commit
* door's `relayAttachFailure` gives in the same file.
* - **The status is declared.** `resolveThrownHttpError`'s `declaredStatus`
* reads `status`, then `statusCode`, then the validation shape, which
* declares `400`. Each limb is driven here.
*
* Both doors (the page's `GET` and the upload's `PUT`) answer through the
* relay; the unregistered-code arm is driven on both.
*
* ⛔ Nothing asserts `toThrow()`: the claim is the envelope's `code` and
* status (ADR-0112).
*/

import { describe, it, expect } from 'vitest';
import { ErrorCode, standardErrorCodeForHttpStatus } from '@objectstack/spec/api';
import type { IHttpRequest, RouteHandler } from '@objectstack/spec/contracts';
import { StorageMetadataStore } from './metadata-store.js';
import { registerStorageFamily } from './storage-routes.js';
import type { UploadLinkDoor } from './record-file-access.js';

const BASE = '/api/v1/storage';
const LINK_PATH = `${BASE}/upload-links/:token`;

/** A producer's own spelling that no ledger registers. */
const UNREGISTERED = 'CLAIM_DESK_SAYS_NO';

interface Answer {
status: number;
body: any;
}

/** The two doors, mounted over a link door whose every call throws `thrown`. */
function doorsThrowing(thrown: unknown) {
const routes = new Map<string, RouteHandler>();
const http = {
get: (p: string, h: RouteHandler) => { routes.set(`GET:${p}`, h); },
post: (p: string, h: RouteHandler) => { routes.set(`POST:${p}`, h); },
put: (p: string, h: RouteHandler) => { routes.set(`PUT:${p}`, h); },
delete: () => {},
patch: () => {},
use: () => {},
listen: async () => {},
close: async () => {},
};
const door: UploadLinkDoor = {
inspectUploadLink: async () => { throw thrown; },
consumeUploadLink: async () => { throw thrown; },
};
registerStorageFamily(
http as any,
{} as any,
new StorageMetadataStore(null),
{ basePath: BASE, resolveSession: async () => ({ userId: 'u_owner' }) } as any,
undefined,
undefined,
() => door,
);

return async (method: 'GET' | 'PUT'): Promise<Answer> => {
const handler = routes.get(`${method}:${LINK_PATH}`);
if (!handler) throw new Error(`fixture: no handler for ${method} ${LINK_PATH}`);
const answer: Answer = { status: 200, body: undefined };
const res: any = {
json(data: any) { answer.body = data; return res; },
send(data: unknown) { answer.body = data; return res; },
status(code: number) { answer.status = code; return res; },
header() { return res; },
};
await handler(
{
params: { token: 'link.secret' },
query: {},
headers: { 'content-type': 'application/pdf', 'x-file-name': 'scan.pdf' },
method,
path: LINK_PATH,
rawBody: async () => Buffer.from('bytes'),
} as unknown as IHttpRequest,
res,
);
return answer;
};
}

const refusal = (fields: Record<string, unknown>): Error => Object.assign(new Error(String(fields.message ?? 'refused')), fields);

describe('upload-link refusal relay · the code is the closed vocabulary\'s', () => {
for (const method of ['GET', 'PUT'] as const) {
it(`${method}: a 4xx refusal spelling an unregistered code answers a registered one, the spelling beside it as declaredCode`, async () => {
expect(ErrorCode.safeParse(UNREGISTERED).success).toBe(false);
const answer = await doorsThrowing(refusal({ code: UNREGISTERED, status: 409, message: 'The claim desk refused it' }))(method);

expect(answer.status).toBe(409);
expect(answer.body.error.code).toBe(standardErrorCodeForHttpStatus(409));
expect(ErrorCode.safeParse(answer.body.error.code).success).toBe(true);
expect(answer.body.error.declaredCode).toBe(UNREGISTERED);
});
}

it('a 503 relays only as spelled SERVICE_UNAVAILABLE: an unregistered spelling stays the route\'s 500, though its status derives that code', async () => {
expect(standardErrorCodeForHttpStatus(503)).toBe('SERVICE_UNAVAILABLE');
const unregistered = await doorsThrowing(refusal({ code: UNREGISTERED, status: 503, message: 'desk down' }))('PUT');
expect(unregistered.status).toBe(500);

const outage = await doorsThrowing(refusal({ code: 'SERVICE_UNAVAILABLE', status: 503, message: 'try later' }))('PUT');
expect(outage.status).toBe(503);
expect(outage.body.error.code).toBe('SERVICE_UNAVAILABLE');
});

it('a registered spelling is answered verbatim, with no declaredCode', async () => {
const answer = await doorsThrowing(refusal({ code: 'PERMISSION_DENIED', status: 403, message: 'You cannot edit that record.' }))('PUT');

expect(answer.status).toBe(403);
expect(answer.body.error.code).toBe('PERMISSION_DENIED');
expect(answer.body.error).not.toHaveProperty('declaredCode');
});
});

describe('upload-link refusal relay · the declared status, over all three limbs', () => {
it('`statusCode` (a lifecycle hook\'s spelling): the record lock answers 409 RECORD_LOCKED', async () => {
const answer = await doorsThrowing(
refusal({ code: 'RECORD_LOCKED', statusCode: 409, message: 'RECORD_LOCKED: Claim is locked while an approval is in progress' }),
)('PUT');

expect(answer.status).toBe(409);
expect(answer.body.error.code).toBe('RECORD_LOCKED');
});

it('the validation shape, which carries no status, answers 400 VALIDATION_FAILED', async () => {
const thrown = refusal({
name: 'ValidationError',
code: 'VALIDATION_FAILED',
fields: [{ field: 'invoices', code: 'invalid_value', message: 'Not an accepted file' }],
message: 'Validation failed',
});
expect(thrown).not.toHaveProperty('status');
expect(thrown).not.toHaveProperty('statusCode');
const answer = await doorsThrowing(thrown)('PUT');

expect(answer.status).toBe(400);
expect(answer.body.error.code).toBe('VALIDATION_FAILED');
});

it('a throw declaring no status is the route\'s 500, not a relayed refusal (control)', async () => {
const answer = await doorsThrowing(refusal({ code: 'PERMISSION_DENIED', message: 'no status' }))('PUT');

expect(answer.status).toBe(500);
});
});
Loading