From ad9220ed8ea1dd4e8baa2403cc7896c0eb9f5a08 Mon Sep 17 00:00:00 2001 From: kalou Date: Fri, 4 Sep 2026 14:31:56 +0200 Subject: [PATCH] feat(pins): add ipfs_pin_count tool and limit/offset pagination for ipfs_list_pins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nodes with large pinsets (hundreds of thousands to millions of pins) make the current ipfs_list_pins tool impractical: it materializes the full pinset and returns every CID through the MCP channel, blowing up context and timing out. - Add core countPins(): streams Kubo `pin/ls --type=all --stream` and tallies counts by type (direct/recursive/indirect/total) with constant memory — no CIDs are accumulated. - Add listPins({limit, offset}) pagination; when limit is set, iteration stops early instead of draining the stream. - Expose both on the Meshkit facade (primary node) and the fil.one/S3 client (count = stored objects, pagination via slice). - MCP: new ipfs_pin_count tool; ipfs_list_pins now accepts optional limit/offset and returns count metadata. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- packages/core/src/create-client.ts | 24 ++++- packages/core/src/create-filone-client.ts | 16 ++- packages/core/src/index.ts | 3 + packages/core/src/meshkit.ts | 9 +- packages/core/src/pin-count.ts | 95 ++++++++++++++++++ packages/core/src/types.ts | 48 ++++++++- packages/core/test/create-client.test.ts | 50 ++++++++++ packages/core/test/helpers/mock-client.ts | 1 + packages/core/test/meshkit.test.ts | 45 +++++++++ packages/core/test/pin-count.test.ts | 114 ++++++++++++++++++++++ packages/mcp/README.md | 3 +- packages/mcp/src/schemas/storage.ts | 20 ++++ packages/mcp/src/tools/storage.ts | 36 ++++++- packages/mcp/test/tools/run-tool.test.ts | 1 + packages/mcp/test/tools/storage.test.ts | 32 +++++- packages/meshkit/src/index.ts | 2 + 16 files changed, 479 insertions(+), 20 deletions(-) create mode 100644 packages/core/src/pin-count.ts create mode 100644 packages/core/test/pin-count.test.ts diff --git a/packages/core/src/create-client.ts b/packages/core/src/create-client.ts index 98797ab..b1c9043 100644 --- a/packages/core/src/create-client.ts +++ b/packages/core/src/create-client.ts @@ -10,7 +10,8 @@ import type { IpnsPublishOptions, IpnsResolveOptions, } from './ipns/types.js'; -import type { MeshkitClient, MeshkitConfig, RetrieveOptions, StoredObject, UploadOptions } from './types.js'; +import { countPinsViaRpc } from './pin-count.js'; +import type { ListPinsOptions, MeshkitClient, MeshkitConfig, RetrieveOptions, StoredObject, UploadOptions } from './types.js'; import { MeshkitError } from './types.js'; function concatChunks(chunks: Uint8Array[], totalLength: number): Uint8Array { @@ -115,12 +116,25 @@ export function createMeshkitClient(config: MeshkitConfig): MeshkitClient { return keys.map((key) => ({ id: key.id, name: key.name })); }, - async listPins(): Promise { - const cids = new Set(); + async listPins(options?: ListPinsOptions): Promise { + const cids: string[] = []; + const offset = options?.offset ?? 0; + let skipped = 0; for await (const { cid } of ipfs.pin.ls({ type: 'all' })) { - cids.add(cid.toString()); + if (skipped < offset) { + skipped++; + continue; + } + cids.push(cid.toString()); + if (options?.limit !== undefined && cids.length >= options.limit) { + break; + } } - return [...cids]; + return cids; + }, + + async countPins() { + return countPinsViaRpc(config.apiUrl, config.headers); }, list() { diff --git a/packages/core/src/create-filone-client.ts b/packages/core/src/create-filone-client.ts index 02ff0e1..2275efc 100644 --- a/packages/core/src/create-filone-client.ts +++ b/packages/core/src/create-filone-client.ts @@ -4,7 +4,7 @@ import { sha256 } from 'multiformats/hashes/sha2'; import * as raw from 'multiformats/codecs/raw'; import { decrypt, encrypt, isEncryptedPayload } from './crypto.js'; import { MeshkitError } from './types.js'; -import type { MeshkitClient, RetrieveOptions, StoredObject, UploadOptions } from './types.js'; +import type { ListPinsOptions, MeshkitClient, PinCount, RetrieveOptions, StoredObject, UploadOptions } from './types.js'; export interface S3StorageConfig { accessKeyId: string; @@ -183,9 +183,19 @@ export function createS3Client(config: S3StorageConfig): MeshkitClient { async pin(_cid: string): Promise {}, - async listPins(): Promise { + async listPins(options?: ListPinsOptions): Promise { const objects = await listAllObjects(); - return objects.map((o) => o.key); + const keys = objects.map((o) => o.key); + const offset = options?.offset ?? 0; + return options?.limit === undefined + ? keys.slice(offset) + : keys.slice(offset, offset + options.limit); + }, + + async countPins(): Promise { + const objects = await listAllObjects(); + const total = objects.length; + return { direct: 0, recursive: total, indirect: 0, total }; }, async list(): Promise { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 91800b7..75e46d4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,8 +11,11 @@ export type { IpnsPublishOptions, IpnsPublishResult, IpnsResolveOptions, + PinCount, + ListPinsOptions, } from './types.js'; export { MeshkitError } from './types.js'; +export { countPinsViaRpc, applyPinLsLine } from './pin-count.js'; export { Meshkit } from './meshkit.js'; diff --git a/packages/core/src/meshkit.ts b/packages/core/src/meshkit.ts index 41e3ff0..25620d1 100644 --- a/packages/core/src/meshkit.ts +++ b/packages/core/src/meshkit.ts @@ -8,6 +8,7 @@ import type { IpnsResolveOptions, } from './ipns/types.js'; import type { + ListPinsOptions, Meshkit as MeshkitFacade, MeshkitClient, MeshkitInitOptions, @@ -92,8 +93,12 @@ export class Meshkit implements MeshkitFacade { return withPrimary(this.clients, (client) => client.listKeys()); } - listPins() { - return withPrimary(this.clients, (client) => client.listPins()); + listPins(options?: ListPinsOptions) { + return withPrimary(this.clients, (client) => client.listPins(options)); + } + + countPins() { + return withPrimary(this.clients, (client) => client.countPins()); } list(): Promise { diff --git a/packages/core/src/pin-count.ts b/packages/core/src/pin-count.ts new file mode 100644 index 0000000..0d258d8 --- /dev/null +++ b/packages/core/src/pin-count.ts @@ -0,0 +1,95 @@ +import { MeshkitError } from './types.js'; +import type { PinCount } from './types.js'; + +interface PinLsLine { + Cid?: string; + Type?: string; + Keys?: Record; + Pins?: string[]; +} + +/** + * Count pins by type on a Kubo node by streaming `pin ls --type=all`. + * + * Tallies counts line-by-line without accumulating CIDs, so memory usage is + * constant regardless of pinset size — unlike `listPins`, which returns the + * full list. Handles the streamed NDJSON format (`{"Cid":...,"Type":...}`) + * and the legacy `Keys` mapping. The legacy `Pins` array format carries no + * pin type and is ignored. + */ +export async function countPinsViaRpc( + apiUrl: string, + headers?: Record, +): Promise { + const url = new URL('/api/v0/pin/ls', apiUrl); + url.searchParams.set('type', 'all'); + url.searchParams.set('stream', 'true'); + + const response = await fetch(url, { + method: 'POST', + ...(headers ? { headers } : {}), + }); + if (!response.ok) { + throw new MeshkitError( + `Failed to list pins at ${apiUrl} (HTTP ${response.status}).`, + ); + } + if (!response.body) { + throw new MeshkitError(`Failed to list pins at ${apiUrl} (empty body).`); + } + + const counts: PinCount = { direct: 0, recursive: 0, indirect: 0, total: 0 }; + let buffer = ''; + const decoder = new TextDecoder(); + + // Node's fetch returns a web ReadableStream, async-iterable on Node 20+. + for await (const chunk of response.body as AsyncIterable) { + buffer += decoder.decode(chunk, { stream: true }); + let newlineIndex: number; + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + applyPinLsLine(buffer.slice(0, newlineIndex), counts); + buffer = buffer.slice(newlineIndex + 1); + } + } + buffer += decoder.decode(); + applyPinLsLine(buffer, counts); + + return counts; +} + +/** Tally a single NDJSON line from Kubo `pin ls` output into `counts`. */ +export function applyPinLsLine(line: string, counts: PinCount): void { + const trimmed = line.trim(); + if (!trimmed) { + return; + } + + let parsed: PinLsLine; + try { + parsed = JSON.parse(trimmed) as PinLsLine; + } catch { + return; + } + + if (parsed.Type) { + bump(counts, parsed.Type); + } + if (parsed.Keys) { + for (const entry of Object.values(parsed.Keys)) { + if (entry?.Type) { + bump(counts, entry.Type); + } + } + } +} + +function bump(counts: PinCount, type: string): void { + if (type === 'direct') { + counts.direct++; + } else if (type === 'recursive') { + counts.recursive++; + } else if (type === 'indirect') { + counts.indirect++; + } + counts.total++; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 3352274..33726d4 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -91,6 +91,33 @@ export interface StoredObject { uploadedAt: string; } +/** + * Pin counts by type, as reported by Kubo `pin ls --type=all`. + * `indirect` pins are deduplicated child blocks of recursive pins. + */ +export interface PinCount { + /** Number of direct pins. */ + direct: number; + /** Number of recursive pins (roots). */ + recursive: number; + /** Number of indirect pins (children of recursive pins). */ + indirect: number; + /** Sum of all pin types. */ + total: number; +} + +/** + * Pagination options for listing pins. + * When `limit` is set, implementations stream and stop early instead of + * materializing the full pinset — important on nodes with millions of pins. + */ +export interface ListPinsOptions { + /** Maximum number of pinned CIDs to return. */ + limit?: number; + /** Number of pins to skip before collecting results. */ + offset?: number; +} + export interface MeshkitClient { /** * Upload raw bytes to the connected IPFS node. Returns the CID string. @@ -141,8 +168,18 @@ export interface MeshkitClient { /** List keys in the node's keystore (includes `"self"`). */ listKeys(): Promise; - /** List all pinned CIDs on the connected node. */ - listPins(): Promise; + /** + * List pinned CIDs on the connected node. + * When `options.limit` is set the pinset is streamed and iteration stops + * early instead of materializing every pin. + */ + listPins(options?: ListPinsOptions): Promise; + + /** + * Count pins by type on the connected node without returning the full list. + * Streams the pinset and tallies counts — safe for very large pinsets. + */ + countPins(): Promise; /** * List all stored objects with metadata. @@ -213,8 +250,11 @@ export interface Meshkit { /** List keys on the primary node's keystore. */ listKeys(): Promise; - /** List all pinned CIDs on the primary node. */ - listPins(): Promise; + /** List pinned CIDs on the primary node (see `ListPinsOptions` for pagination). */ + listPins(options?: ListPinsOptions): Promise; + + /** Count pins by type on the primary node without returning the full list. */ + countPins(): Promise; /** * List all stored objects with metadata. diff --git a/packages/core/test/create-client.test.ts b/packages/core/test/create-client.test.ts index c847bdf..828dbe9 100644 --- a/packages/core/test/create-client.test.ts +++ b/packages/core/test/create-client.test.ts @@ -246,6 +246,56 @@ describe('createMeshkitClient', () => { expect(ipfs.pin.ls).toHaveBeenCalledWith({ type: 'all' }); }); + it('listPins stops early when a limit is given', async () => { + const yielded: string[] = []; + async function* pins() { + for (const cid of ['QmA', 'QmB', 'QmC', 'QmD']) { + yielded.push(cid); + yield { cid: { toString: () => cid }, type: 'recursive' }; + } + } + ipfs.pin.ls.mockReturnValue(pins()); + + const client = createMeshkitClient({ apiUrl: 'http://127.0.0.1:5001' }); + await expect(client.listPins({ limit: 2 })).resolves.toEqual(['QmA', 'QmB']); + // Iterator stopped after the limit instead of draining the pinset. + expect(yielded).toEqual(['QmA', 'QmB']); + }); + + it('listPins skips offset pins before collecting', async () => { + async function* pins() { + for (const cid of ['QmA', 'QmB', 'QmC']) { + yield { cid: { toString: () => cid }, type: 'recursive' }; + } + } + ipfs.pin.ls.mockReturnValue(pins()); + + const client = createMeshkitClient({ apiUrl: 'http://127.0.0.1:5001' }); + await expect(client.listPins({ offset: 1, limit: 1 })).resolves.toEqual([ + 'QmB', + ]); + }); + + it('countPins streams the pinset and returns counts by type', async () => { + const body = [ + '{"Cid":"QmA","Type":"recursive"}', + '{"Cid":"QmB","Type":"direct"}', + '{"Cid":"QmC","Type":"indirect"}', + ].join('\n'); + const fetchMock = vi.fn(async () => new Response(body)); + vi.stubGlobal('fetch', fetchMock); + + const client = createMeshkitClient({ apiUrl: 'http://127.0.0.1:5001' }); + await expect(client.countPins()).resolves.toEqual({ + direct: 1, + recursive: 1, + indirect: 1, + total: 3, + }); + + vi.unstubAllGlobals(); + }); + it('healthCheck calls ipfs.id()', async () => { ipfs.id.mockResolvedValue({}); diff --git a/packages/core/test/helpers/mock-client.ts b/packages/core/test/helpers/mock-client.ts index 3135821..d40c8be 100644 --- a/packages/core/test/helpers/mock-client.ts +++ b/packages/core/test/helpers/mock-client.ts @@ -16,6 +16,7 @@ export function createMockClient( generateKey: async () => ({ name: 'self', id: 'QmSelf' }), listKeys: async () => [{ name: 'self', id: 'QmSelf' }], listPins: async () => [], + countPins: async () => ({ direct: 0, recursive: 0, indirect: 0, total: 0 }), healthCheck: async () => undefined, ...overrides, }; diff --git a/packages/core/test/meshkit.test.ts b/packages/core/test/meshkit.test.ts index 5d30b2e..51ee921 100644 --- a/packages/core/test/meshkit.test.ts +++ b/packages/core/test/meshkit.test.ts @@ -245,4 +245,49 @@ describe('Meshkit operations', () => { expect(listPins).toHaveBeenCalledOnce(); expect(secondaryListPins).not.toHaveBeenCalled(); }); + + it('listPins passes pagination options to the primary client', async () => { + const listPins = vi.fn(async () => ['QmB']); + + vi.spyOn(health, 'filterHealthy').mockResolvedValue({ + clients: [createMockClient({ listPins })], + urls: ['http://primary:5001'], + failed: [], + }); + + const mk = await Meshkit.init({ nodes: ['http://primary:5001'] }); + + await expect(mk.listPins({ limit: 1, offset: 1 })).resolves.toEqual(['QmB']); + expect(listPins).toHaveBeenCalledWith({ limit: 1, offset: 1 }); + }); + + it('countPins uses primary node only', async () => { + const countPins = vi.fn( + async () => ({ direct: 0, recursive: 2, indirect: 1, total: 3 }), + ); + const secondaryCountPins = vi.fn(); + + vi.spyOn(health, 'filterHealthy').mockResolvedValue({ + clients: [ + createMockClient({ countPins }), + createMockClient({ countPins: secondaryCountPins }), + ], + urls: ['http://primary:5001', 'http://secondary:5001'], + failed: [], + }); + + const mk = await Meshkit.init({ + nodes: ['http://primary:5001', 'http://secondary:5001'], + }); + + await expect(mk.countPins()).resolves.toEqual({ + direct: 0, + recursive: 2, + indirect: 1, + total: 3, + }); + + expect(countPins).toHaveBeenCalledOnce(); + expect(secondaryCountPins).not.toHaveBeenCalled(); + }); }); diff --git a/packages/core/test/pin-count.test.ts b/packages/core/test/pin-count.test.ts new file mode 100644 index 0000000..a22b412 --- /dev/null +++ b/packages/core/test/pin-count.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { applyPinLsLine, countPinsViaRpc } from '../src/pin-count.js'; +import { MeshkitError } from '../src/types.js'; +import type { PinCount } from '../src/types.js'; + +function counts(): PinCount { + return { direct: 0, recursive: 0, indirect: 0, total: 0 }; +} + +describe('applyPinLsLine', () => { + it('tallies streamed lines with a Type field', () => { + const c = counts(); + applyPinLsLine('{"Cid":"QmFoo","Type":"recursive"}', c); + applyPinLsLine('{"Cid":"QmBar","Type":"direct"}', c); + applyPinLsLine('{"Cid":"QmBaz","Type":"indirect"}', c); + + expect(c).toEqual({ direct: 1, recursive: 1, indirect: 1, total: 3 }); + }); + + it('tallies legacy Keys mapping entries', () => { + const c = counts(); + applyPinLsLine('{"Keys":{"QmA":{"Type":"recursive"},"QmB":{"Type":"recursive"}}}', c); + + expect(c).toEqual({ direct: 0, recursive: 2, indirect: 0, total: 2 }); + }); + + it('ignores blank and malformed lines', () => { + const c = counts(); + applyPinLsLine('', c); + applyPinLsLine(' ', c); + applyPinLsLine('not json', c); + + expect(c).toEqual(counts()); + }); + + it('counts unknown types into total only', () => { + const c = counts(); + applyPinLsLine('{"Cid":"QmX","Type":"meta"}', c); + + expect(c).toEqual({ direct: 0, recursive: 0, indirect: 0, total: 1 }); + }); +}); + +describe('countPinsViaRpc', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('streams pin ls output and returns counts by type', async () => { + const body = [ + '{"Cid":"QmA","Type":"recursive"}', + '{"Cid":"QmB","Type":"recursive"}', + '{"Cid":"QmC","Type":"indirect"}', + '{"Cid":"QmD","Type":"direct"}', + '', + ].join('\n'); + + const fetchMock = vi.fn(async () => new Response(body)); + vi.stubGlobal('fetch', fetchMock); + + await expect(countPinsViaRpc('http://127.0.0.1:5001')).resolves.toEqual({ + direct: 1, + recursive: 2, + indirect: 1, + total: 4, + }); + + const [requestUrl, requestInit] = fetchMock.mock.calls[0]!; + expect(String(requestUrl)).toBe( + 'http://127.0.0.1:5001/api/v0/pin/ls?type=all&stream=true', + ); + expect(requestInit).toEqual({ method: 'POST' }); + }); + + it('sends custom headers when provided', async () => { + const fetchMock = vi.fn(async () => new Response('')); + vi.stubGlobal('fetch', fetchMock); + + await countPinsViaRpc('http://127.0.0.1:5001', { + Authorization: 'Bearer test', + }); + + const [, requestInit] = fetchMock.mock.calls[0]!; + expect(requestInit).toEqual({ + method: 'POST', + headers: { Authorization: 'Bearer test' }, + }); + }); + + it('handles lines split across chunk boundaries', async () => { + const body = + '{"Cid":"QmA","Type":"recur' + 'sive"}\n{"Cid":"QmB","Type":"direct"}\n'; + const fetchMock = vi.fn(async () => new Response(body)); + vi.stubGlobal('fetch', fetchMock); + + await expect(countPinsViaRpc('http://127.0.0.1:5001')).resolves.toEqual({ + direct: 1, + recursive: 1, + indirect: 0, + total: 2, + }); + }); + + it('throws MeshkitError on HTTP failure', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('boom', { status: 500 })), + ); + + await expect(countPinsViaRpc('http://127.0.0.1:5001')).rejects.toBeInstanceOf( + MeshkitError, + ); + }); +}); diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 2e55818..298ead6 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -93,7 +93,8 @@ Add to `claude_desktop_config.json`: | `ipfs_upload` | Upload text or base64 content; returns CID. Supports optional `password` and `pbkdf2Iterations` for client-side AES-256-GCM encryption before upload | | `ipfs_retrieve` | Retrieve content by CID. Pass `password` to decrypt if the content was uploaded encrypted | | `ipfs_pin` | Pin a CID on the node | -| `ipfs_list_pins` | List all pinned CIDs on the primary node | +| `ipfs_list_pins` | List pinned CIDs on the primary node. Optional `limit`/`offset` page through large pinsets without draining the full stream | +| `ipfs_pin_count` | Count pins by type (`direct`, `recursive`, `indirect`, `total`) on the primary node. Streams and returns only counts — safe for nodes with millions of pins | | `ipfs_publish_name` | Publish an IPNS record | | `ipfs_resolve` | Resolve an IPNS name and retrieve content | | `ipfs_generate_key` | Create a named IPNS signing key | diff --git a/packages/mcp/src/schemas/storage.ts b/packages/mcp/src/schemas/storage.ts index c1160bd..04a38b7 100644 --- a/packages/mcp/src/schemas/storage.ts +++ b/packages/mcp/src/schemas/storage.ts @@ -58,6 +58,26 @@ export const pinSchema = { cid: z.string().describe('IPFS CID to pin'), }; +export const listPinsShape = { + limit: z + .number() + .int() + .min(1) + .optional() + .describe( + 'Maximum number of pinned CIDs to return. Recommended on nodes with ' + + 'large pinsets — the pinset is streamed and iteration stops early ' + + 'instead of returning every pin.', + ), + offset: z + .number() + .int() + .min(0) + .optional() + .describe('Number of pins to skip before collecting results (default 0).'), +}; + export type UploadInput = z.infer; export type RetrieveInput = z.infer>; export type PinInput = z.infer>; +export type ListPinsInput = z.infer>; diff --git a/packages/mcp/src/tools/storage.ts b/packages/mcp/src/tools/storage.ts index 8d51e19..34ed2ee 100644 --- a/packages/mcp/src/tools/storage.ts +++ b/packages/mcp/src/tools/storage.ts @@ -7,9 +7,11 @@ import { type UploadInput as RawUploadInput, } from '../format.js'; import { + listPinsShape, pinSchema, retrieveSchema, uploadShape, + type ListPinsInput, type PinInput, type RetrieveInput, type UploadInput, @@ -63,9 +65,25 @@ export async function handlePin( export async function handleListPins( ctx: MeshkitContext, + input: ListPinsInput = {}, ): Promise> { - const pins = await ctx.meshkit.listPins(); - return textResult({ pins }); + const pins = await ctx.meshkit.listPins({ + ...(input.limit !== undefined ? { limit: input.limit } : {}), + ...(input.offset !== undefined ? { offset: input.offset } : {}), + }); + return textResult({ + count: pins.length, + ...(input.limit !== undefined ? { limit: input.limit } : {}), + ...(input.offset !== undefined ? { offset: input.offset } : {}), + pins, + }); +} + +export async function handlePinCount( + ctx: MeshkitContext, +): Promise> { + const counts = await ctx.meshkit.countPins(); + return textResult(counts); } export function registerStorageTools( @@ -95,7 +113,17 @@ export function registerStorageTools( server.tool( 'ipfs_list_pins', - 'List all pinned CIDs on the primary node', - async () => runToolNoInput(ctx, handleListPins), + 'List pinned CIDs on the primary node. Use limit/offset to page through ' + + 'large pinsets — prefer ipfs_pin_count when you only need the number of pins.', + listPinsShape, + async (input: ListPinsInput) => runTool(ctx, handleListPins, input), + ); + + server.tool( + 'ipfs_pin_count', + 'Count pins by type (direct, recursive, indirect, total) on the primary ' + + 'node. Streams the pinset and returns only counts — safe for nodes ' + + 'with millions of pins.', + async () => runToolNoInput(ctx, handlePinCount), ); } diff --git a/packages/mcp/test/tools/run-tool.test.ts b/packages/mcp/test/tools/run-tool.test.ts index 70039cf..43fefe9 100644 --- a/packages/mcp/test/tools/run-tool.test.ts +++ b/packages/mcp/test/tools/run-tool.test.ts @@ -18,6 +18,7 @@ function createMockContext(): MeshkitContext { generateKey: vi.fn(), listKeys: vi.fn(), listPins: vi.fn(), + countPins: vi.fn(), }, }; } diff --git a/packages/mcp/test/tools/storage.test.ts b/packages/mcp/test/tools/storage.test.ts index efe66fd..1f76c1c 100644 --- a/packages/mcp/test/tools/storage.test.ts +++ b/packages/mcp/test/tools/storage.test.ts @@ -5,6 +5,7 @@ import { uploadSchema } from '../../src/schemas/storage.js'; import { handleListPins, handlePin, + handlePinCount, handleRetrieve, handleUpload, } from '../../src/tools/storage.js'; @@ -23,6 +24,9 @@ function createMockContext(): MeshkitContext { generateKey: vi.fn(), listKeys: vi.fn(), listPins: vi.fn().mockResolvedValue(['QmA', 'QmB']), + countPins: vi + .fn() + .mockResolvedValue({ direct: 1, recursive: 2, indirect: 3, total: 6 }), }, }; } @@ -104,9 +108,35 @@ describe('storage tool handlers', () => { const result = await handleListPins(ctx); - expect(ctx.meshkit.listPins).toHaveBeenCalled(); + expect(ctx.meshkit.listPins).toHaveBeenCalledWith({}); expect(result.content[0]?.text).toContain('QmA'); expect(result.content[0]?.text).toContain('QmB'); + expect(result.content[0]?.text).toContain('"count": 2'); + }); + + it('handleListPins passes limit and offset through to meshkit', async () => { + const ctx = createMockContext(); + vi.mocked(ctx.meshkit.listPins).mockResolvedValue(['QmB']); + + const result = await handleListPins(ctx, { limit: 1, offset: 1 }); + + expect(ctx.meshkit.listPins).toHaveBeenCalledWith({ limit: 1, offset: 1 }); + expect(result.content[0]?.text).toContain('"limit": 1'); + expect(result.content[0]?.text).toContain('"offset": 1'); + expect(result.content[0]?.text).toContain('"count": 1'); + }); + + it('handlePinCount returns counts by type without the pin list', async () => { + const ctx = createMockContext(); + + const result = await handlePinCount(ctx); + + expect(ctx.meshkit.countPins).toHaveBeenCalled(); + expect(result.content[0]?.text).toContain('"direct": 1'); + expect(result.content[0]?.text).toContain('"recursive": 2'); + expect(result.content[0]?.text).toContain('"indirect": 3'); + expect(result.content[0]?.text).toContain('"total": 6'); + expect(result.content[0]?.text).not.toContain('pins'); }); it('handleUpload surfaces MeshkitError messages', async () => { diff --git a/packages/meshkit/src/index.ts b/packages/meshkit/src/index.ts index dad3621..908b395 100644 --- a/packages/meshkit/src/index.ts +++ b/packages/meshkit/src/index.ts @@ -11,6 +11,8 @@ export type { IpnsPublishOptions, IpnsPublishResult, IpnsResolveOptions, + PinCount, + ListPinsOptions, } from '@ipfs-meshkit/core'; export { MeshkitError,