@@ -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
9698export interface McpObjectSummary {
9799 name : string ;
@@ -824,10 +826,13 @@ const RECORD_ID_ALIASES = { id: 'recordId', record_id: 'recordId' } as const;
824826 */
825827export 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+
18211989function 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 ) ;
0 commit comments