From cc8e2c8f01e33eb76ad098ee6b0071f61add3b12 Mon Sep 17 00:00:00 2001 From: DEls Date: Mon, 5 Oct 2026 01:42:27 +0200 Subject: [PATCH] feat: expose purchasing details and protected source documents --- .../mcp/documents/[kind]/[id]/route.test.ts | 23 +++++++ .../api/mcp/documents/[kind]/[id]/route.ts | 13 ++++ src/lib/purchasing/documents.test.ts | 26 ++++++++ src/lib/purchasing/documents.ts | 54 ++++++++++++++++ src/lib/purchasing/reads.test.ts | 8 +++ src/lib/purchasing/reads.ts | 61 +++++++++++++++++++ src/mcp/create-server.ts | 2 + src/mcp/http.test.ts | 2 +- src/mcp/purchasing-read-tools.ts | 27 ++++++++ 9 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 src/app/api/mcp/documents/[kind]/[id]/route.test.ts create mode 100644 src/app/api/mcp/documents/[kind]/[id]/route.ts create mode 100644 src/lib/purchasing/documents.test.ts create mode 100644 src/lib/purchasing/documents.ts create mode 100644 src/lib/purchasing/reads.test.ts create mode 100644 src/lib/purchasing/reads.ts create mode 100644 src/mcp/purchasing-read-tools.ts diff --git a/src/app/api/mcp/documents/[kind]/[id]/route.test.ts b/src/app/api/mcp/documents/[kind]/[id]/route.test.ts new file mode 100644 index 0000000..23efaea --- /dev/null +++ b/src/app/api/mcp/documents/[kind]/[id]/route.test.ts @@ -0,0 +1,23 @@ +import {afterEach,beforeEach,expect,it,vi} from 'vitest'; +import {hash} from 'node:crypto'; +const mocks=vi.hoisted(()=>({context:vi.fn(),document:vi.fn()})); +vi.mock('@/lib/purchasing/client',()=>({createPurchasingContext:mocks.context})); +vi.mock('@/lib/purchasing/documents',async importOriginal=>({...await importOriginal(),loadDocument:mocks.document})); +import {GET} from './route'; +const token='document-test-token-of-sufficient-length'; +beforeEach(()=>{ + vi.stubEnv('MCP_PUBLIC_URL','https://mega.test/api/mcp'); + vi.stubEnv('MCP_CLIENTS_JSON',JSON.stringify([{id:'read',tokenHash:hash('sha256',token),permissions:['read']}])); + mocks.context.mockReset();mocks.document.mockReset(); + mocks.context.mockResolvedValue({});mocks.document.mockResolvedValue({bytes:new TextEncoder().encode('%PDF-fixture'),mime:'application/pdf',filename:'étude.pdf'}); +}); +afterEach(()=>vi.unstubAllEnvs()); +it('rejects missing auth before PB or file access',async()=>{ + const res=await GET(new Request('https://mega.test/api/mcp/documents/po/abcdefghijklmn1'),{params:Promise.resolve({kind:'po',id:'abcdefghijklmn1'})}); + expect(res.status).toBe(401);expect(mocks.context).not.toHaveBeenCalled();expect(mocks.document).not.toHaveBeenCalled(); +}); +it('returns protected file bytes and Unicode attachment metadata',async()=>{ + const res=await GET(new Request('https://mega.test/api/mcp/documents/po/abcdefghijklmn1',{headers:{Authorization:`Bearer ${token}`}}),{params:Promise.resolve({kind:'po',id:'abcdefghijklmn1'})}); + expect(res.status).toBe(200);expect(await res.text()).toBe('%PDF-fixture'); + expect(res.headers.get('Content-Disposition')).toContain('%C3%A9tude.pdf');expect(res.headers.get('Cache-Control')).toContain('no-store'); +}); diff --git a/src/app/api/mcp/documents/[kind]/[id]/route.ts b/src/app/api/mcp/documents/[kind]/[id]/route.ts new file mode 100644 index 0000000..0b5e81b --- /dev/null +++ b/src/app/api/mcp/documents/[kind]/[id]/route.ts @@ -0,0 +1,13 @@ +import {authenticateMcp,requirePermission,McpHttpError} from '@/mcp/auth'; +import {createPurchasingContext} from '@/lib/purchasing/client'; +import {loadDocument,parseDocumentReference} from '@/lib/purchasing/documents'; +import {PurchasingError} from '@/lib/purchasing/errors'; +export const runtime='nodejs';export const dynamic='force-dynamic'; +export async function GET(request:Request,{params}:{params:Promise<{kind:string;id:string}>}):Promise{ + try{ + const principal=authenticateMcp(request);requirePermission(principal,'read'); + const reference=parseDocumentReference(await params); + const document=await loadDocument(await createPurchasingContext(principal),reference); + return new Response(new Uint8Array(document.bytes),{headers:{'Content-Type':document.mime,'Content-Disposition':`attachment; filename="document"; filename*=UTF-8''${encodeURIComponent(document.filename).replace(/'/g,'%27')}`,'Content-Length':String(document.bytes.length),'Cache-Control':'private, no-store','X-Content-Type-Options':'nosniff'}}); + }catch(error){return Response.json({error:error instanceof PurchasingError||error instanceof McpHttpError?error.message:'document_unavailable'},{status:error instanceof PurchasingError||error instanceof McpHttpError?error.status:502,headers:{'Cache-Control':'no-store'}});} +} diff --git a/src/lib/purchasing/documents.test.ts b/src/lib/purchasing/documents.test.ts new file mode 100644 index 0000000..a46939f --- /dev/null +++ b/src/lib/purchasing/documents.test.ts @@ -0,0 +1,26 @@ +import {afterEach,expect,it,vi} from 'vitest'; +import type PocketBase from 'pocketbase'; +import {parseDocumentReference,safeAttachmentName,documentDownloadUrl,loadDocument} from './documents'; +afterEach(()=>{vi.restoreAllMocks();vi.unstubAllEnvs();}); +it('rejects paths, URLs and unknown reference kinds',()=>{ + expect(()=>parseDocumentReference({url:'https://evil.test'})).toThrow(); + expect(()=>parseDocumentReference({kind:'po',id:'../secret'})).toThrow(); + expect(()=>parseDocumentReference({kind:'users',id:'abcdefghijklmn1'})).toThrow(); +}); +it('preserves Unicode and strips unsafe attachment characters',()=>{ + expect(safeAttachmentName('../étude"\r\n.pdf')).toBe('étude.pdf'); + vi.stubEnv('MCP_PUBLIC_URL','https://mega.test/api/mcp'); + expect(documentDownloadUrl({kind:'po',id:'abcdefghijklmn1'})).toBe('https://mega.test/api/mcp/documents/po/abcdefghijklmn1'); +}); +it('reads a protected original and returns file bytes without upstream credentials',async()=>{ + const pb={collection:()=>({getOne:async()=>({id:'abcdefghijklmn1',collectionName:'purchase_orders',purchase_order_file:'invoice.pdf'})}),files:{getToken:async()=>'private-file-token',getURL:()=> 'https://pb.test/api/files/purchase_orders/abcdefghijklmn1/invoice.pdf?token=private-file-token'},authStore:{token:'private-admin'}} as unknown as PocketBase; + vi.spyOn(globalThis,'fetch').mockResolvedValue(new Response('%PDF-fixture',{headers:{'Content-Type':'application/pdf'}})); + const doc=await loadDocument({pb,principal:{id:'read',permissions:new Set(['read'])}},{kind:'po',id:'abcdefghijklmn1'}); + expect(Buffer.from(doc.bytes).toString()).toBe('%PDF-fixture');expect(doc.filename).toBe('invoice.pdf'); + expect(JSON.stringify(doc)).not.toContain('private'); +}); +it('bounds document reads even if the server omits Content-Length',async()=>{ + const pb={collection:()=>({getOne:async()=>({id:'abcdefghijklmn1',file:'source.pdf'})}),files:{getToken:async()=>'token',getURL:()=> 'https://pb.test/source'},authStore:{token:'admin'}} as unknown as PocketBase; + vi.spyOn(globalThis,'fetch').mockResolvedValue(new Response(new Uint8Array(20*1024*1024+1))); + await expect(loadDocument({pb,principal:{id:'read',permissions:new Set(['read'])}},{kind:'source',id:'abcdefghijklmn1'})).rejects.toThrow('document_too_large'); +}); diff --git a/src/lib/purchasing/documents.ts b/src/lib/purchasing/documents.ts new file mode 100644 index 0000000..6edbcbb --- /dev/null +++ b/src/lib/purchasing/documents.ts @@ -0,0 +1,54 @@ +import {z} from 'zod'; +import {PDFDocument} from 'pdf-lib'; +import type {RecordModel} from 'pocketbase'; +import type {DocumentReference,PurchasingContext} from './types'; +import {PurchasingError} from './errors'; +export const RecordId=z.string().regex(/^[a-z0-9]{15}$/); +export const DocumentReferenceSchema=z.object({kind:z.enum(['po','receipt','source','page','candidate']),id:RecordId}).strict(); +export function parseDocumentReference(input:unknown):DocumentReference{return DocumentReferenceSchema.parse(input);} +export function safeAttachmentName(filename:string):string{return filename.split(/[\\/]/).pop()!.replace(/[\x00-\x1f\x7f"<>]/g,'').slice(0,200)||'document';} +export function documentDownloadUrl(reference:DocumentReference):string { + parseDocumentReference(reference); + const base=new URL(process.env.MCP_PUBLIC_URL||''); + return new URL(`/api/mcp/documents/${reference.kind}/${reference.id}`,base).toString(); +} +const MAX_BYTES=20*1024*1024; +async function boundedBytes(response:Response):Promise{ + if(!response.ok)throw new PurchasingError('document_unavailable',502); + if(Number(response.headers.get('content-length'))>MAX_BYTES)throw new PurchasingError('document_too_large',413); + if(!response.body)throw new PurchasingError('document_empty',502); + const reader=response.body.getReader(),parts:Uint8Array[]=[];let size=0; + try{for(;;){const next=await reader.read();if(next.done)break;size+=next.value.byteLength;if(size>MAX_BYTES){await reader.cancel();throw new PurchasingError('document_too_large',413);}parts.push(next.value);}}finally{reader.releaseLock();} + return new Uint8Array(Buffer.concat(parts)); +} +export async function loadDocument(ctx:PurchasingContext,reference:DocumentReference):Promise<{bytes:Uint8Array;mime:string;filename:string}>{ + const ref=parseDocumentReference(reference); + if(ref.kind==='candidate'){ + const note=await ctx.pb.collection('dn_batch_notes').getOne(ref.id,{fields:'id,batch,pages'}); + const saved:Array=Array.isArray(note.pages)?note.pages:[]; + if(!saved.length||saved.length>100||new Set(saved).size!==saved.length)throw new PurchasingError('invalid_candidate_pages'); + const rows=await ctx.pb.collection('dn_batch_pages').getFullList({filter:ctx.pb.filter('batch={:batch}',{batch:note.batch}),fields:'id,batch,global_page,image'}); + const pdf=await PDFDocument.create();let total=0; + for(const page of saved){ + const matches=rows.filter(row=>row.batch===note.batch&&row.global_page===page); + if(matches.length!==1)throw new PurchasingError('candidate_source_page_missing',404); + const source=await loadDocument(ctx,{kind:'page',id:matches[0].id});total+=source.bytes.length; + if(total>MAX_BYTES)throw new PurchasingError('document_too_large',413); + const image=source.mime==='image/jpeg'?await pdf.embedJpg(source.bytes):source.mime==='image/png'?await pdf.embedPng(source.bytes):null; + if(!image)throw new PurchasingError('source_page_type_unsupported'); + const sheet=pdf.addPage([image.width,image.height]);sheet.drawImage(image,{x:0,y:0,width:image.width,height:image.height}); + } + const bytes=await pdf.save();if(bytes.length>MAX_BYTES)throw new PurchasingError('document_too_large',413); + return {bytes,mime:'application/pdf',filename:`delivery-${ref.id}.pdf`}; + } + const mapping={po:['purchase_orders','purchase_order_file'],receipt:['delivery_notes','slip_image'],source:['dn_batch_sources','file'],page:['dn_batch_pages','image']} as const; + const [collection,field]=mapping[ref.kind]; + const record=await ctx.pb.collection(collection).getOne(ref.id,{fields:`id,collectionId,${field}`}); + const filename=record[field];if(typeof filename!=='string'||!filename)throw new PurchasingError('document_not_found',404); + const url=ctx.pb.files.getURL({...record,collectionName:collection} as RecordModel,filename,{token:await ctx.pb.files.getToken()}); + const upstream=await fetch(url,{headers:{Authorization:ctx.pb.authStore.token},signal:AbortSignal.timeout(60000)}); + const mime=(upstream.headers.get('content-type')||'application/octet-stream').split(';')[0].toLowerCase(); + const allowed=new Set(['application/pdf','image/png','image/jpeg','image/webp']); + const bytes=await boundedBytes(upstream); + return {bytes,mime:allowed.has(mime)?mime:'application/octet-stream',filename:safeAttachmentName(filename)}; +} diff --git a/src/lib/purchasing/reads.test.ts b/src/lib/purchasing/reads.test.ts new file mode 100644 index 0000000..6a18157 --- /dev/null +++ b/src/lib/purchasing/reads.test.ts @@ -0,0 +1,8 @@ +import {expect,it} from 'vitest'; +import {ListPurchasingSchema} from './reads'; +it('bounds pagination and prevents arbitrary collection access',()=>{ + expect(()=>ListPurchasingSchema.parse({entity:'users'})).toThrow(); + expect(()=>ListPurchasingSchema.parse({entity:'purchase_orders',pageSize:1001})).toThrow(); + expect(()=>ListPurchasingSchema.parse({entity:'purchase_orders',filter:'true'})).toThrow(); + expect(ListPurchasingSchema.parse({entity:'purchase_orders'}).pageSize).toBe(50); +}); diff --git a/src/lib/purchasing/reads.ts b/src/lib/purchasing/reads.ts new file mode 100644 index 0000000..38663e3 --- /dev/null +++ b/src/lib/purchasing/reads.ts @@ -0,0 +1,61 @@ +import {z} from 'zod'; +import type {PurchasingContext} from './types'; +import {RecordId} from './documents'; +import {PurchasingError} from './errors'; +import {computeOutstanding,type DeliveryLineLike} from '@/lib/po-fulfilment'; +import {reconcilePoDelivery,type ReconciliationReceipt,type ReconciliationCandidate} from '@/lib/po-delivery-reconciliation'; +import type {DocumentKind} from '@/lib/document-kind'; +const entities={ + purchase_orders:'id,site,purchase_order_number,supplier,supplier_ref,order_date,required_by_date,ordered_by,subtotal,vat_amount,total_amount,is_verified,processing_status,purchase_order_file,created,updated', + purchase_order_items:'id,purchase_order,description,material_code,cost_code,qty,unit,rate,disc_percent,vat_percent,amount,created,updated', + delivery_notes:'id,site,purchase_order,delivery_note_number,date_received,received_by_name,document_type,is_verified,slip_image,processing_status,created,updated', + delivery_note_items:'id,delivery_note,purchase_order_item,qty_received,qty_raw,unit_raw,condition_notes,booking,allocation_key,source_line_keys', + dn_batches:'id,site,status,stage,page_count,source_count,file_hash,assembly_revision,revision,created,updated', + dn_batch_sources:'id,batch,ordinal,mime,original_name,file_hash,file,created', + dn_batch_pages:'id,batch,source,source_page,global_page,region,image,mime,image_hash', + dn_batch_notes:'id,batch,pages,doc_number,doc_number_alts,delivery_note_number,supplier,supplier_name_raw,purchase_order,items,checks,status,stage,reconciliation_preview,manual_overrides,line_posting,delivery_note,posted_at,assembly_revision,candidate_revision', + suppliers:'id,name,created,updated', + supplier_reconciliation_rules:'id,supplier,scope,status,title,instruction,examples,created,updated', + supplier_document_aliases:'id,supplier,raw_value,normalised_key,source,approved_by', + dn_item_aliases:'id,supplier_key,slip_description_key,purchase_order_item,po_description,times_used', +} as const; +export const EntitySchema=z.enum(Object.keys(entities) as [keyof typeof entities,...(keyof typeof entities)[]]); +export const ListPurchasingSchema=z.object({entity:EntitySchema,site:RecordId.optional(),status:z.string().max(80).optional(),page:z.number().int().min(1).max(10000).default(1),pageSize:z.number().int().min(1).max(100).default(50)}).strict(); +export const GetPurchasingSchema=z.object({entity:EntitySchema,id:RecordId}).strict(); +export const GetPoSchema=z.object({id:RecordId}).strict(); +export const GetCandidateSchema=z.object({batchId:RecordId,id:RecordId}).strict(); +export async function readPurchasing(ctx:PurchasingContext,action:'list_purchasing_records'|'get_purchasing_record'|'get_purchase_order_detail'|'get_delivery_candidate'|'get_delivery_reconciliation',input:Record):Promise>{ + const pb=ctx.pb; + if(action==='list_purchasing_records'){ + const args=ListPurchasingSchema.parse(input);const filters:string[]=[]; + if(args.site){if(!['purchase_orders','delivery_notes','dn_batches'].includes(args.entity))throw new PurchasingError('entity_has_no_site_filter');filters.push(pb.filter('site={:site}',{site:args.site}));} + if(args.status){const field=args.entity==='purchase_orders'||args.entity==='delivery_notes'?'processing_status':'status';if(!['purchase_orders','delivery_notes','dn_batches','dn_batch_notes','supplier_reconciliation_rules'].includes(args.entity))throw new PurchasingError('entity_has_no_status_filter');filters.push(pb.filter(`${field}={:status}`,{status:args.status}));} + const result=await pb.collection(args.entity).getList(args.page,args.pageSize,{filter:filters.join(' && '),sort:'id',fields:entities[args.entity]});return {...result}; + } + if(action==='get_purchasing_record'){const args=GetPurchasingSchema.parse(input);return {...await pb.collection(args.entity).getOne(args.id,{fields:entities[args.entity]})};} + if(action==='get_delivery_candidate'){ + const args=GetCandidateSchema.parse(input);const note=await pb.collection('dn_batch_notes').getOne(args.id,{fields:entities.dn_batch_notes}); + if(note.batch!==args.batchId)throw new PurchasingError('candidate_batch_mismatch'); + const pages=await pb.collection('dn_batch_pages').getFullList({filter:pb.filter('batch={:batch}',{batch:args.batchId}),fields:entities.dn_batch_pages}); + return {note,pages:pages.filter(page=>Array.isArray(note.pages)&¬e.pages.includes(page.global_page)).sort((a,b)=>note.pages.indexOf(a.global_page)-note.pages.indexOf(b.global_page))}; + } + const {id}=GetPoSchema.parse(input); + const po=await pb.collection('purchase_orders').getOne(id,{fields:entities.purchase_orders}); + const [items,deliveries,candidates]=await Promise.all([ + pb.collection('purchase_order_items').getFullList({filter:pb.filter('purchase_order={:id}',{id}),fields:entities.purchase_order_items}), + pb.collection('delivery_notes').getFullList({filter:pb.filter('purchase_order={:id}',{id}),fields:entities.delivery_notes}), + pb.collection('dn_batch_notes').getFullList({filter:pb.filter("purchase_order={:id} && status!='promoted' && status!='dismissed'",{id}),fields:entities.dn_batch_notes}), + ]); + const receipts:ReconciliationReceipt[]=[];const fulfilmentLines:DeliveryLineLike[]=[]; + for(const delivery of deliveries){ + const lines=await pb.collection('delivery_note_items').getFullList({filter:pb.filter('delivery_note={:id}',{id:delivery.id}),fields:entities.delivery_note_items}); + receipts.push({id:delivery.id,number:String(delivery.delivery_note_number??''),documentType:String(delivery.document_type??'') as DocumentKind,lines:lines.map(line=>({purchaseOrderItem:String(line.purchase_order_item??''),quantity:Number(line.qty_received??0),booking:line.booking}))}); + fulfilmentLines.push(...lines.map(line=>({purchase_order_item:String(line.purchase_order_item??''),qty_received:Number(line.qty_received??0),document_type:String(delivery.document_type??'') as DocumentKind}))); + } + if(action==='get_purchase_order_detail'){ + const fulfilment=computeOutstanding(items.map(item=>({id:item.id,qty:Number(item.qty??0)})),fulfilmentLines); + return {po,items:items.map(item=>({...item,fulfilment:fulfilment.get(item.id)})),deliveries,document:po.purchase_order_file?{kind:'po',id}:null}; + } + const snapshot=reconcilePoDelivery(items.map(item=>({id:item.id,description:String(item.description??''),unit:String(item.unit??''),rate:item.rate==null?NaN:Number(item.rate),orderedQuantity:item.qty==null?null:Number(item.qty)})),receipts,candidates.map(note=>({id:note.id,batchId:String(note.batch??''),number:String(note.doc_number||note.delivery_note_number||''),pages:Array.isArray(note.pages)?note.pages:[],status:String(note.status??''),checks:Array.isArray(note.checks)?note.checks:[],items:Array.isArray(note.items)?note.items:[]} as ReconciliationCandidate))); + return {po,generatedAt:new Date().toISOString(),...snapshot}; +} diff --git a/src/mcp/create-server.ts b/src/mcp/create-server.ts index 8119c3f..869a948 100644 --- a/src/mcp/create-server.ts +++ b/src/mcp/create-server.ts @@ -2,6 +2,7 @@ import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'; import {tools} from '@/lib/agent/tools'; import type {Principal} from '@/lib/purchasing/types'; import {requirePermission} from './auth'; +import {registerPurchasingReads} from './purchasing-read-tools'; export function createMcpServer(principal:Principal):McpServer { const server=new McpServer({name:'mega-infrastructure',version:'1.1.0'}); if(principal.permissions.has('read'))for(const tool of tools){ @@ -18,5 +19,6 @@ export function createMcpServer(principal:Principal):McpServer { } }); } + registerPurchasingReads(server,principal); return server; } diff --git a/src/mcp/http.test.ts b/src/mcp/http.test.ts index 19b35ba..c81a4b0 100644 --- a/src/mcp/http.test.ts +++ b/src/mcp/http.test.ts @@ -15,7 +15,7 @@ it('initializes using the actual SDK without a cookie or session',async()=>{ }); it('discovers and calls read tools through fresh requests',async()=>{ const listed=await handleMcp(req({jsonrpc:'2.0',id:2,method:'tools/list',params:{}})); - expect((await listed.json()).result.tools.map((t:{name:string})=>t.name)).toEqual(['fixture_read']); + expect((await listed.json()).result.tools.map((t:{name:string})=>t.name)).toContain('fixture_read'); const called=await handleMcp(req({jsonrpc:'2.0',id:3,method:'tools/call',params:{name:'fixture_read',arguments:{}}})); expect(JSON.parse((await called.json()).result.content[0].text)).toEqual({ok:true}); }); diff --git a/src/mcp/purchasing-read-tools.ts b/src/mcp/purchasing-read-tools.ts new file mode 100644 index 0000000..2ab708f --- /dev/null +++ b/src/mcp/purchasing-read-tools.ts @@ -0,0 +1,27 @@ +import {z} from 'zod'; +import type {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js'; +import type {Principal} from '@/lib/purchasing/types'; +import {registerPurchasingTool} from './tool-contract'; +import {ListPurchasingSchema,GetPurchasingSchema,GetPoSchema,GetCandidateSchema,readPurchasing} from '@/lib/purchasing/reads'; +import {DocumentReferenceSchema,loadDocument,documentDownloadUrl} from '@/lib/purchasing/documents'; +import {PurchasingError} from '@/lib/purchasing/errors'; +import {getOperation} from '@/lib/purchasing/operations'; +export function registerPurchasingReads(server:McpServer,principal:Principal):void{ + const detail=[ + ['list_purchasing_records','List purchasing records with bounded pagination.',ListPurchasingSchema], + ['get_purchasing_record','Get an allowed purchasing record and related operational fields.',GetPurchasingSchema], + ['get_purchase_order_detail','Get PO lines, receipt quantities and outstanding quantities.',GetPoSchema], + ['get_delivery_candidate','Get a delivery candidate, matching decisions and source page references.',GetCandidateSchema], + ['get_delivery_reconciliation','Compare an order, posted receipts and unposted candidates.',GetPoSchema], + ] as const; + for(const [name,description,schema] of detail)registerPurchasingTool(server,principal,{name,description,schema,permission:'read',handler:(ctx,input)=>readPurchasing(ctx,name,input)}); + registerPurchasingTool(server,principal,{name:'get_document_download',description:'Get document metadata and a same-app download URL requiring the MCP bearer header.',permission:'read',schema:DocumentReferenceSchema,handler:async(ctx,input)=>{ + const ref=DocumentReferenceSchema.parse(input),doc=await loadDocument(ctx,ref);return {filename:doc.filename,mime:doc.mime,size:doc.bytes.length,url:documentDownloadUrl(ref),authentication:'Authorization: Bearer '}; + }}); + registerPurchasingTool(server,principal,{name:'read_document',description:'Read a bounded binary document segment as base64. Iterate offset for larger files.',permission:'read',schema:DocumentReferenceSchema.extend({offset:z.number().int().min(0).default(0),length:z.number().int().min(1).max(2*1024*1024).default(2*1024*1024)}).strict(),handler:async(ctx,input)=>{ + const {kind,id,offset,length}=DocumentReferenceSchema.extend({offset:z.number(),length:z.number()}).parse(input); + const doc=await loadDocument(ctx,{kind,id});if(offset>=doc.bytes.length)throw new PurchasingError('document_range_invalid',416); + const slice=doc.bytes.slice(offset,offset+length);return {filename:doc.filename,mime:doc.mime,size:doc.bytes.length,offset,length:slice.length,nextOffset:offset+slice.lengthgetOperation(ctx,String(input.operationId))}); +}