Skip to content

Commit 75bc8cf

Browse files
committed
feat(mcp): read_file and create_upload_link read and attach a record's files (#30892)
Two MCP tools over the storage plugin's file-attachments service: - read_file reads a file a record carries (a file field's, or an attachment) as the caller: the record is read through the get_record door first, the file is judged by the download door's own verdict, and the answer carries the size, the byte cap, a short-lived download link and the extracted text (text, PDF via unpdf 1.9.1, .docx) or the image. - create_upload_link mints a single-use upload link bound to one record and field (or the attachments list). The link is a pending sys_file row holding the SHA-256 of a 256-bit secret; one conditional write spends it; the upload acts as the minting user, re-resolved at use, and meets max_upload_mb before the link is spent. Work in progress: runtime tests, docs and changeset follow. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4cyqd3A7fbCWrqM3KCz
1 parent 4d56914 commit 75bc8cf

19 files changed

Lines changed: 3239 additions & 34 deletions

‎packages/mcp/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
"@objectstack/formula": "workspace:*",
3333
"@objectstack/spec": "workspace:*",
3434
"@objectstack/types": "workspace:*",
35+
"unpdf": "1.9.1",
3536
"zod": "^4.6.6"
3637
},
3738
"devDependencies": {

‎packages/mcp/src/file-content.ts‎

Lines changed: 466 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/mcp/src/mcp-file-tools.test.ts‎

Lines changed: 420 additions & 0 deletions
Large diffs are not rendered by default.

‎packages/mcp/src/mcp-http-tools.ts‎

Lines changed: 169 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ import {
9292
metadataPartialListingSentence,
9393
type DiagnosedObjectListing,
9494
} from './metadata-completeness.js';
95+
// [#30892] The file seam and what `read_file` renders from it.
96+
import { READ_FILE_MAX_BYTES, renderReadFile, type McpFileBridge } from './file-content.js';
9597

9698
export interface McpObjectSummary {
9799
name: string;
@@ -824,10 +826,13 @@ const RECORD_ID_ALIASES = { id: 'recordId', record_id: 'recordId' } as const;
824826
*/
825827
export function wireBridgeTools(
826828
server: McpServer,
827-
bridge: McpDataBridge & Partial<McpActionBridge> & Partial<McpFederationBridge>,
829+
bridge: McpDataBridge & Partial<McpActionBridge> & Partial<McpFederationBridge> & McpFileBridge,
828830
options: RegisterObjectToolsOptions & RegisterActionToolsOptions = {},
829831
): string[] {
830832
const registered = registerObjectTools(server, bridge, options);
833+
// [#30892] `read_file` / `create_upload_link`, each only where the bridge
834+
// implements its member.
835+
registered.push(...registerFileTools(server, bridge, options));
831836
if (typeof bridge.listActions === 'function' && typeof bridge.runAction === 'function') {
832837
registered.push(...registerActionTools(server, bridge as McpActionBridge, options));
833838
}
@@ -1818,6 +1823,169 @@ export function registerActionTools(
18181823
return registered;
18191824
}
18201825

1826+
/**
1827+
* [#30892] Register the file pair over {@link McpFileBridge}: `read_file`
1828+
* (with `data:read`) reads a file a record carries, and `create_upload_link`
1829+
* (with `data:write`) mints a single-use link through which the user uploads
1830+
* a file onto a record. Each registers only where the bridge implements its
1831+
* member, so a host that cannot reach a record's files serves neither.
1832+
*
1833+
* Neither tool judges access itself. The bridge reads the record as the
1834+
* caller first (the `get_record` door), so a record the caller cannot read is
1835+
* refused here with the code `get_record` answers; the storage service then
1836+
* asks the download door's own verdict for the file, and the attach rule's
1837+
* for a link. `sys_*` objects are refused as every object tool refuses them.
1838+
*/
1839+
export function registerFileTools(
1840+
server: McpServer,
1841+
bridge: McpFileBridge,
1842+
options: RegisterObjectToolsOptions = {},
1843+
): string[] {
1844+
const registered: string[] = [];
1845+
const note = (name: string): string => {
1846+
registered.push(name);
1847+
return name;
1848+
};
1849+
const allowSystem = options.allowSystemObjects === true;
1850+
const scopes = options.grantedScopes;
1851+
const canRead = !scopes || scopes.includes(MCP_OAUTH_SCOPE_DATA_READ);
1852+
const canWrite = !scopes || scopes.includes(MCP_OAUTH_SCOPE_DATA_WRITE);
1853+
const guard = (objectName: string): string | undefined => {
1854+
if (!objectName || typeof objectName !== 'string') return 'objectName is required';
1855+
if (!allowSystem && isSystemObject(objectName)) {
1856+
return `Object "${objectName}" is a system object and is not exposed via MCP`;
1857+
}
1858+
return undefined;
1859+
};
1860+
1861+
if (canRead && typeof bridge.readRecordFile === 'function') {
1862+
const readFn = bridge.readRecordFile.bind(bridge);
1863+
const listFn = typeof bridge.listRecordFiles === 'function' ? bridge.listRecordFiles.bind(bridge) : undefined;
1864+
server.registerTool(
1865+
note('read_file'),
1866+
{
1867+
description:
1868+
'Read a file a record carries: one held by a file field (field, plus fileId when the field holds several) ' +
1869+
'or one of its attachments (attachmentId). Answers the file\'s facts (name, type, size in bytes, the ' +
1870+
`byte cap read_file reads, ${READ_FILE_MAX_BYTES}) with a short-lived download link, then its content: the ` +
1871+
'text of a text file, a PDF or a Word (.docx) document, or the image itself. A larger file answers its ' +
1872+
'facts and the link only. ' +
1873+
(listFn
1874+
? 'Name neither field nor attachmentId to list the files on the record you can read, with their ids. '
1875+
: '') +
1876+
'You can read a file only where you can read its record: a record you cannot read answers as not found.',
1877+
inputSchema: strictToolInput(
1878+
{
1879+
surface: 'this read_file call',
1880+
aliases: {
1881+
...OBJECT_NAME_ALIASES,
1882+
...RECORD_ID_ALIASES,
1883+
fieldName: 'field',
1884+
field_name: 'field',
1885+
file_id: 'fileId',
1886+
attachment_id: 'attachmentId',
1887+
attachment: 'attachmentId',
1888+
},
1889+
},
1890+
{
1891+
objectName: z.string().describe('The object/table name'),
1892+
recordId: z.string().describe('The record id'),
1893+
field: z.string().optional().describe('A file or image field of the record'),
1894+
fileId: z
1895+
.string()
1896+
.optional()
1897+
.describe('Which file, when the field holds several (a multi-file field); with field only'),
1898+
attachmentId: z.string().optional().describe('One of the record\'s attachments, by its id'),
1899+
},
1900+
),
1901+
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
1902+
},
1903+
async ({ objectName, recordId, field, fileId, attachmentId }) => {
1904+
const bad = guard(objectName);
1905+
if (bad) return errorResult(bad);
1906+
if (field !== undefined && attachmentId !== undefined) {
1907+
return errorResult('Name one file: either field (with fileId for a multi-file field) or attachmentId, not both.');
1908+
}
1909+
if (fileId !== undefined && field === undefined) {
1910+
return errorResult('fileId names a file within a field: send field with it.');
1911+
}
1912+
try {
1913+
if (field === undefined && attachmentId === undefined) {
1914+
if (!listFn) return errorResult('Name the file to read: field (a file field of the record) or attachmentId.');
1915+
return textResult({ files: await listFn(objectName, recordId) });
1916+
}
1917+
const target = attachmentId !== undefined ? { attachmentId } : { field: field as string, ...(fileId !== undefined ? { fileId } : {}) };
1918+
const file = await readFn(objectName, recordId, target, { maxBytes: READ_FILE_MAX_BYTES });
1919+
return { content: await renderReadFile(file) };
1920+
} catch (err) {
1921+
return errorResultWithCode(err);
1922+
}
1923+
},
1924+
);
1925+
}
1926+
1927+
if (canWrite && typeof bridge.createUploadLink === 'function') {
1928+
const linkFn = bridge.createUploadLink.bind(bridge);
1929+
server.registerTool(
1930+
note('create_upload_link'),
1931+
{
1932+
description:
1933+
'Create a link through which the user uploads one file onto a record: into a file field (field) or ' +
1934+
'as an attachment (attachments: true). Give the link to the user to open; it works once, for this ' +
1935+
'record and field only, and expires (see expiresAt). The upload is stored as the user, so they must be ' +
1936+
'able to edit the record (and the field).',
1937+
inputSchema: strictToolInput(
1938+
{
1939+
surface: 'this create_upload_link call',
1940+
aliases: {
1941+
...OBJECT_NAME_ALIASES,
1942+
...RECORD_ID_ALIASES,
1943+
fieldName: 'field',
1944+
field_name: 'field',
1945+
attachment: 'attachments',
1946+
},
1947+
},
1948+
{
1949+
objectName: z.string().describe('The object/table name'),
1950+
recordId: z.string().describe('The record id'),
1951+
field: z.string().optional().describe('A file or image field of the record the upload fills'),
1952+
attachments: z
1953+
.literal(true)
1954+
.optional()
1955+
.describe('true to attach the upload to the record instead of filling a field'),
1956+
},
1957+
),
1958+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
1959+
},
1960+
async ({ objectName, recordId, field, attachments }) => {
1961+
const bad = guard(objectName);
1962+
if (bad) return errorResult(bad);
1963+
if ((field === undefined) === (attachments === undefined)) {
1964+
return errorResult('Say where the upload lands: either field (a file field of the record) or attachments: true.');
1965+
}
1966+
try {
1967+
const target = field !== undefined ? { field } : { attachments: true as const };
1968+
const link = await linkFn(objectName, recordId, target);
1969+
return textResult({
1970+
url: link.url,
1971+
expiresAt: link.expiresAt,
1972+
singleUse: true,
1973+
objectName,
1974+
recordId,
1975+
...(field !== undefined ? { field } : { attachments: true }),
1976+
instructions:
1977+
'Give this link to the user to open in a browser and pick the file. It works once, until expiresAt.',
1978+
});
1979+
} catch (err) {
1980+
return errorResultWithCode(err);
1981+
}
1982+
},
1983+
);
1984+
}
1985+
1986+
return registered;
1987+
}
1988+
18211989
function messageOf(err: unknown): string {
18221990
if (err instanceof Error) return err.message;
18231991
if (err && typeof err === 'object' && 'message' in err) return String((err as any).message);

‎packages/mcp/src/skill-md-surface-guard.test.ts‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { AIToolDefinition } from '@objectstack/spec/contracts';
1111

1212
import { MCPServerRuntime } from './mcp-server-runtime.js';
1313
import type { McpDataBridge, McpActionBridge } from './mcp-http-tools.js';
14+
import type { McpFileBridge } from './file-content.js';
1415
import type { McpAuthoringToolRegistry, ToolExecutionResult } from './types.js';
1516
import { renderSkillMarkdown } from './skill-md.js';
1617

@@ -34,8 +35,8 @@ import { renderSkillMarkdown } from './skill-md.js';
3435
* tool to `mcp-http-tools.ts` without teaching the skill turns this red.
3536
*/
3637

37-
/** Minimal full-surface bridge: object + action methods present, all stubbed. */
38-
function makeFullBridge(): McpDataBridge & McpActionBridge {
38+
/** Minimal full-surface bridge: object + action + file methods present, all stubbed. */
39+
function makeFullBridge(): McpDataBridge & McpActionBridge & McpFileBridge {
3940
return {
4041
async listObjects() {
4142
return [];
@@ -69,6 +70,18 @@ function makeFullBridge(): McpDataBridge & McpActionBridge {
6970
async resumeRun() {
7071
return {};
7172
},
73+
// [#30892] The file pair's members, so `read_file` / `create_upload_link`
74+
// are on the surface this guard reads: a host with the storage plugin
75+
// carries them, and its skill is rendered with `fileTools: true`.
76+
async listRecordFiles() {
77+
return [];
78+
},
79+
async readRecordFile() {
80+
return { fileId: 'f', name: 'f', mimeType: null, size: null, maxBytes: 0, download: null };
81+
},
82+
async createUploadLink() {
83+
return { url: 'https://x.test/u', expiresAt: '2026-01-01T00:00:00.000Z' };
84+
},
7285
};
7386
}
7487

@@ -132,7 +145,7 @@ describe('SKILL.md ↔ native tool surface drift guard', () => {
132145
// Sanity: the harness must see the full surface, or the guard guards nothing.
133146
expect(registered.length).toBeGreaterThanOrEqual(9);
134147

135-
const md = renderSkillMarkdown();
148+
const md = renderSkillMarkdown({ fileTools: true });
136149
const undocumented = registered.filter((name) => !md.includes(name));
137150
expect(undocumented, `tools registered but missing from SKILL.md — update packages/mcp/src/skill-md.ts: ${undocumented.join(', ')}`).toEqual([]);
138151
});
@@ -145,7 +158,7 @@ describe('SKILL.md ↔ native tool surface drift guard', () => {
145158
for (const t of AUTHORING_TOOLS) expect(registered).toContain(t.name);
146159

147160
// The skill rendered for the SAME grant and the SAME registry names every one of them …
148-
const md = renderSkillMarkdown({ grantedScopes: EVERY_SCOPE, authoringTools: registry.getAll() });
161+
const md = renderSkillMarkdown({ grantedScopes: EVERY_SCOPE, authoringTools: registry.getAll(), fileTools: true });
149162
const undocumented = registered.filter((name) => !md.includes(name));
150163
expect(undocumented, `tools registered but missing from the authoring SKILL.md: ${undocumented.join(', ')}`).toEqual([]);
151164

‎packages/mcp/src/skill-md.ts‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
*/
3232

3333
import type { AIToolDefinition } from '@objectstack/spec/contracts';
34-
import { MCP_OAUTH_SCOPE_DATA_READ, MCP_OAUTH_SCOPE_METADATA_WRITE } from '@objectstack/spec/ai';
34+
import { MCP_OAUTH_SCOPE_DATA_READ, MCP_OAUTH_SCOPE_DATA_WRITE, MCP_OAUTH_SCOPE_METADATA_WRITE } from '@objectstack/spec/ai';
3535
import { grantsAuthoringTools } from './mcp-http-tools.js';
3636

3737
/** Skill identity (mirrors the `SKILL.md` YAML frontmatter). */
@@ -100,6 +100,15 @@ export interface RenderSkillOptions {
100100
* renderer cannot see the bridge, so the host says so.
101101
*/
102102
federationTools?: boolean;
103+
/**
104+
* [#30892] Whether the host's bridge carries the file pair (`read_file` /
105+
* `create_upload_link`, `registerFileTools`). Each entry renders only when
106+
* this is true AND {@link grantedScopes} admit its family (`read_file`:
107+
* `data:read`; `create_upload_link`: `data:write`), so the skill names them
108+
* exactly where `tools/list` lists them. Default false: the renderer cannot
109+
* see the bridge, so the host says so.
110+
*/
111+
fileTools?: boolean;
103112
}
104113

105114
const URL_PLACEHOLDER = '<YOUR_ENV_MCP_URL>'; // e.g. https://<env>.objectos.app/api/v1/mcp
@@ -165,6 +174,34 @@ const FEDERATION_TOOL_LINES = `- **list_remote_tables({ datasource, schema? })**
165174
\`review\` names lossy columns and left-out navigations.
166175
`;
167176

177+
/** [#30892] `read_file`'s entry, rendered where `registerFileTools` registers it (`data:read`). */
178+
const READ_FILE_TOOL_LINES = `- **read_file({ objectName, recordId, field?, fileId?, attachmentId? })** —
179+
read a file a record carries: one in a file field (\`field\`, plus \`fileId\`
180+
for a multi-file field) or one attachment (\`attachmentId\`). Answers the
181+
file's name, type and size, the byte cap it reads, and a short-lived download
182+
link, then the text of a text file, PDF or Word document, or the image
183+
itself. Send neither \`field\` nor \`attachmentId\` to list the record's
184+
files with their ids. A record you cannot read answers as not found.
185+
`;
186+
187+
/** [#30892] `create_upload_link`'s entry, rendered where `registerFileTools` registers it (`data:write`). */
188+
const UPLOAD_LINK_TOOL_LINES = `- **create_upload_link({ objectName, recordId, field? | attachments: true })** —
189+
a link through which the user uploads one file onto a record: into a file
190+
field, or as an attachment. Give the link to the user; it works once, for
191+
that record and field only, and expires at \`expiresAt\`. The upload is
192+
stored as the user, so they must be able to edit the record.
193+
`;
194+
195+
/** The file pair's entries for a host whose bridge carries them, each for the grant that registers it. */
196+
function renderFileToolLines(options: RenderSkillOptions): string {
197+
if (options.fileTools !== true) return '';
198+
const scopes = options.grantedScopes;
199+
// Mirrors the gates in `registerFileTools` (`!scopes || scopes.includes(…)`).
200+
const read = !scopes || scopes.includes(MCP_OAUTH_SCOPE_DATA_READ);
201+
const write = !scopes || scopes.includes(MCP_OAUTH_SCOPE_DATA_WRITE);
202+
return (read ? READ_FILE_TOOL_LINES : '') + (write ? UPLOAD_LINK_TOOL_LINES : '');
203+
}
204+
168205
/**
169206
* Render the full `SKILL.md` (YAML frontmatter + body). Pass the env's MCP URL
170207
* to produce a ready-to-install skill; the body is otherwise generic.
@@ -308,7 +345,7 @@ create/update payload.
308345
screen, which you fill with another \`resume_run\`. Only the user whose call
309346
started the run can resume it, under the same gates as \`run_action\`
310347
(including \`confirm: true\` for an action flagged \`requiresConfirmation\`).
311-
${options.federationTools === true && grantsAuthoringTools(options.grantedScopes) ? FEDERATION_TOOL_LINES : ''}${authoringTools ? renderAuthoringSection(authoringTools, describeObjectRegistered) : ''}
348+
${renderFileToolLines(options)}${options.federationTools === true && grantsAuthoringTools(options.grantedScopes) ? FEDERATION_TOOL_LINES : ''}${authoringTools ? renderAuthoringSection(authoringTools, describeObjectRegistered) : ''}
312349
## Conventions & gotchas
313350
314351
- **Permissions/RLS apply to every call.** Fewer rows than expected, or a

0 commit comments

Comments
 (0)