@hotstack/catalogue-client 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # @hotstack/catalogue-client
2
+
3
+ A thin, typed, dependency-free client for the [HotStack Catalogue](https://github.com/hotstacklabs/hotstack-mcp-catalog)
4
+ read API — search MCP servers and plugins, fetch full entries, and read the
5
+ curated registry projection. Global `fetch` only; no runtime dependencies.
6
+
7
+ This is the **read/discovery** SDK. It never asserts that a listed server is
8
+ safe (the catalogue records provenance, not endorsements), and it has no auth
9
+ or tool-execution surface — that is a separate product.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ npm install @hotstack/catalogue-client
15
+ ```
16
+
17
+ Node ≥ 20, or any runtime with a global `fetch` (pass `fetch` explicitly for
18
+ older ones).
19
+
20
+ ## Quickstart
21
+
22
+ ```ts
23
+ import { CatalogueClient } from '@hotstack/catalogue-client';
24
+
25
+ const client = new CatalogueClient({ baseUrl: 'https://api.hotstack.dev' });
26
+
27
+ // Search (filters + sort mirror GET /v1/search).
28
+ const page = await client.search({ q: 'gmail', category: 'communication', sort: 'newest', limit: 10 });
29
+ console.log(page.total, page.results[0]?.id);
30
+
31
+ // Walk every match without hand-rolling pagination.
32
+ for await (const hit of client.searchAll({ category: 'data' })) {
33
+ console.log(hit.id, hit.tier);
34
+ }
35
+
36
+ // Fetch one entry (its inline `mcp` config is on `entry`, ready to paste).
37
+ const detail = await client.entry('stripe/stripe-mcp');
38
+ console.log(detail.entry);
39
+ ```
40
+
41
+ ## Honesty semantics
42
+
43
+ The catalogue never fabricates data to look healthy, and neither does this
44
+ client:
45
+
46
+ - **Degraded vs empty.** When no source has ever ingested successfully the API
47
+ returns `503`; the client throws `CatalogueUnavailableError` (never an empty
48
+ result). A legitimate zero-hit search is a normal `SearchPage` with
49
+ `total: 0`.
50
+ - **Stale data.** `client.registry()` surfaces the `stale` flag
51
+ (`x-hotstack-stale`) so you can tell current data from last-good data.
52
+ - **ETag round-trip.** Pass the `etag` from a prior `registry()` call to
53
+ revalidate; an unchanged index returns `{ status: 'not_modified' }`.
54
+
55
+ ```ts
56
+ import { CatalogueUnavailableError, EntryNotFoundError } from '@hotstack/catalogue-client';
57
+
58
+ try {
59
+ await client.entry('nope/missing');
60
+ } catch (error) {
61
+ if (error instanceof EntryNotFoundError) { /* 404 */ }
62
+ if (error instanceof CatalogueUnavailableError) { /* index down, not empty */ }
63
+ }
64
+ ```
65
+
66
+ ## API
67
+
68
+ - `new CatalogueClient({ baseUrl, fetch?, userAgent? })`
69
+ - `search(params): Promise<SearchPage>` — `GET /v1/search`
70
+ - `searchAll(params): AsyncIterableIterator<SearchHit>` — pages until done
71
+ - `entry(id): Promise<EntryDetail>` — `GET /v1/entries/:id` (404 → `EntryNotFoundError`)
72
+ - `registry({ etag? }): Promise<RegistryResult>` — `GET /v1/registry`
73
+ - `sources(): Promise<SourceStatus[]>` — `GET /v1/sources`
74
+
75
+ The full parameter and response reference is [`docs/api.md`](https://github.com/hotstacklabs/hotstack-mcp-catalog/blob/main/docs/api.md).
76
+
77
+ ## License
78
+
79
+ MIT.
@@ -0,0 +1,32 @@
1
+ import type { EntryDetail, RegistryResult, SearchHit, SearchPage, SearchParams, SourceStatus } from './types.js';
2
+ export interface CatalogueClientOptions {
3
+ /** Catalogue service origin, e.g. "https://api.hotstack.dev". Trailing slashes are trimmed. */
4
+ baseUrl: string;
5
+ /** Fetch implementation; defaults to the global `fetch`. */
6
+ fetch?: typeof fetch;
7
+ /** Optional User-Agent sent with every request (ignored by browsers). */
8
+ userAgent?: string;
9
+ }
10
+ export declare class CatalogueClient {
11
+ #private;
12
+ constructor(options: CatalogueClientOptions);
13
+ /** GET /v1/search. Throws CatalogueUnavailableError on the degraded 503. */
14
+ search(params?: SearchParams): Promise<SearchPage>;
15
+ /**
16
+ * Walk every page of a search, yielding each hit once. Pages at the server's
17
+ * max limit and stops on `hasMore: false`. Honors an explicit `offset` start.
18
+ */
19
+ searchAll(params?: SearchParams): AsyncIterableIterator<SearchHit>;
20
+ /** GET /v1/entries/:id. Throws EntryNotFoundError on 404, CatalogueUnavailableError on 503. */
21
+ entry(id: string): Promise<EntryDetail>;
22
+ /**
23
+ * GET /v1/registry. Pass `etag` from a prior call for a conditional request:
24
+ * an unchanged index returns `{status: 'not_modified'}`. Surfaces the strong
25
+ * ETag and the honest `stale` flag; throws CatalogueUnavailableError on 503.
26
+ */
27
+ registry(opts?: {
28
+ etag?: string;
29
+ }): Promise<RegistryResult>;
30
+ /** GET /v1/sources — per-source ingest status. Always 200 when the service is up. */
31
+ sources(): Promise<SourceStatus[]>;
32
+ }
package/dist/client.js ADDED
@@ -0,0 +1,145 @@
1
+ // The catalogue read client: a thin, typed wrapper over the three stable read
2
+ // endpoints plus /v1/sources. Zero runtime dependencies — global `fetch` only
3
+ // (inject one for older runtimes or tests). Read/discovery only: this SDK never
4
+ // grows auth or tool-execution features (that is a separate product).
5
+ import { CatalogueApiError, CatalogueUnavailableError, EntryNotFoundError } from './errors.js';
6
+ /**
7
+ * The server's `limit` clamp (parseSearchParams, MAX_LIMIT in
8
+ * src/api/search.ts). searchAll pages at this size so it never over-requests.
9
+ */
10
+ const MAX_LIMIT = 100;
11
+ function buildSearchQuery(params) {
12
+ const query = new URLSearchParams();
13
+ if (params.q !== undefined)
14
+ query.set('q', params.q);
15
+ if (params.category !== undefined)
16
+ query.set('category', params.category);
17
+ if (params.kind !== undefined)
18
+ query.set('kind', params.kind);
19
+ if (params.tier !== undefined)
20
+ query.set('tier', params.tier);
21
+ if (params.publisher !== undefined)
22
+ query.set('publisher', params.publisher);
23
+ if (params.sort !== undefined)
24
+ query.set('sort', params.sort);
25
+ if (params.limit !== undefined)
26
+ query.set('limit', String(params.limit));
27
+ if (params.offset !== undefined)
28
+ query.set('offset', String(params.offset));
29
+ const encoded = query.toString();
30
+ return encoded.length > 0 ? `?${encoded}` : '';
31
+ }
32
+ async function readBody(response) {
33
+ const text = await response.text();
34
+ try {
35
+ return JSON.parse(text);
36
+ }
37
+ catch {
38
+ return text;
39
+ }
40
+ }
41
+ export class CatalogueClient {
42
+ #baseUrl;
43
+ #fetch;
44
+ #userAgent;
45
+ constructor(options) {
46
+ if (typeof options.baseUrl !== 'string' || options.baseUrl.length === 0) {
47
+ throw new TypeError('CatalogueClient requires a non-empty baseUrl');
48
+ }
49
+ this.#baseUrl = options.baseUrl.replace(/\/+$/, '');
50
+ const fetchImpl = options.fetch ?? globalThis.fetch;
51
+ if (typeof fetchImpl !== 'function') {
52
+ throw new TypeError('global fetch is unavailable in this runtime; pass options.fetch');
53
+ }
54
+ this.#fetch = fetchImpl;
55
+ this.#userAgent = options.userAgent;
56
+ }
57
+ async #request(path, init) {
58
+ const headers = new Headers(init?.headers);
59
+ headers.set('accept', 'application/json');
60
+ if (this.#userAgent !== undefined)
61
+ headers.set('user-agent', this.#userAgent);
62
+ // Call through a local so `fetch` keeps its own `this` (native fetch is not
63
+ // bound to the client instance).
64
+ const doFetch = this.#fetch;
65
+ return doFetch(`${this.#baseUrl}${path}`, { ...init, headers });
66
+ }
67
+ /** GET /v1/search. Throws CatalogueUnavailableError on the degraded 503. */
68
+ async search(params = {}) {
69
+ const path = `/v1/search${buildSearchQuery(params)}`;
70
+ const response = await this.#request(path);
71
+ if (response.status === 503)
72
+ throw new CatalogueUnavailableError({ status: 503, url: this.#baseUrl + path, body: await readBody(response) });
73
+ if (!response.ok) {
74
+ throw new CatalogueApiError(`search failed with ${response.status}`, { status: response.status, url: this.#baseUrl + path, body: await readBody(response) });
75
+ }
76
+ return (await response.json());
77
+ }
78
+ /**
79
+ * Walk every page of a search, yielding each hit once. Pages at the server's
80
+ * max limit and stops on `hasMore: false`. Honors an explicit `offset` start.
81
+ */
82
+ async *searchAll(params = {}) {
83
+ const pageSize = Math.min(params.limit ?? MAX_LIMIT, MAX_LIMIT);
84
+ let offset = params.offset ?? 0;
85
+ for (;;) {
86
+ const page = await this.search({ ...params, limit: pageSize, offset });
87
+ for (const hit of page.results)
88
+ yield hit;
89
+ if (!page.hasMore || page.results.length === 0)
90
+ break;
91
+ offset += page.results.length;
92
+ }
93
+ }
94
+ /** GET /v1/entries/:id. Throws EntryNotFoundError on 404, CatalogueUnavailableError on 503. */
95
+ async entry(id) {
96
+ // Ids are publisher-namespaced ("publisher/name"); the literal "/" stays in
97
+ // the path, each segment percent-encoded.
98
+ const path = `/v1/entries/${id.split('/').map(encodeURIComponent).join('/')}`;
99
+ const response = await this.#request(path);
100
+ if (response.status === 404)
101
+ throw new EntryNotFoundError(id, { status: 404, url: this.#baseUrl + path, body: await readBody(response) });
102
+ if (response.status === 503)
103
+ throw new CatalogueUnavailableError({ status: 503, url: this.#baseUrl + path, body: await readBody(response) });
104
+ if (!response.ok) {
105
+ throw new CatalogueApiError(`entry lookup failed with ${response.status}`, { status: response.status, url: this.#baseUrl + path, body: await readBody(response) });
106
+ }
107
+ return (await response.json());
108
+ }
109
+ /**
110
+ * GET /v1/registry. Pass `etag` from a prior call for a conditional request:
111
+ * an unchanged index returns `{status: 'not_modified'}`. Surfaces the strong
112
+ * ETag and the honest `stale` flag; throws CatalogueUnavailableError on 503.
113
+ */
114
+ async registry(opts = {}) {
115
+ const path = '/v1/registry';
116
+ const headers = new Headers();
117
+ if (opts.etag !== undefined)
118
+ headers.set('if-none-match', opts.etag);
119
+ const response = await this.#request(path, { headers });
120
+ if (response.status === 304)
121
+ return { status: 'not_modified' };
122
+ if (response.status === 503)
123
+ throw new CatalogueUnavailableError({ status: 503, url: this.#baseUrl + path, body: await readBody(response) });
124
+ if (!response.ok) {
125
+ throw new CatalogueApiError(`registry fetch failed with ${response.status}`, { status: response.status, url: this.#baseUrl + path, body: await readBody(response) });
126
+ }
127
+ const index = (await response.json());
128
+ return {
129
+ status: 'ok',
130
+ index,
131
+ etag: response.headers.get('etag') ?? '',
132
+ stale: response.headers.get('x-hotstack-stale') === 'true',
133
+ };
134
+ }
135
+ /** GET /v1/sources — per-source ingest status. Always 200 when the service is up. */
136
+ async sources() {
137
+ const path = '/v1/sources';
138
+ const response = await this.#request(path);
139
+ if (!response.ok) {
140
+ throw new CatalogueApiError(`sources fetch failed with ${response.status}`, { status: response.status, url: this.#baseUrl + path, body: await readBody(response) });
141
+ }
142
+ const body = (await response.json());
143
+ return body.sources;
144
+ }
145
+ }
@@ -0,0 +1,23 @@
1
+ export interface CatalogueApiErrorInit {
2
+ status: number;
3
+ url: string;
4
+ /** Parsed JSON body when available, else the raw text. */
5
+ body: unknown;
6
+ }
7
+ export declare class CatalogueApiError extends Error {
8
+ readonly status: number;
9
+ readonly url: string;
10
+ readonly body: unknown;
11
+ constructor(message: string, init: CatalogueApiErrorInit);
12
+ }
13
+ /** GET /v1/entries/:id → 404: the id is not in the index. */
14
+ export declare class EntryNotFoundError extends CatalogueApiError {
15
+ constructor(id: string, init: CatalogueApiErrorInit);
16
+ }
17
+ /**
18
+ * The API's explicit 503 degraded response — no configured source has ever
19
+ * ingested successfully. Distinct from a 200 empty/zero-hit result.
20
+ */
21
+ export declare class CatalogueUnavailableError extends CatalogueApiError {
22
+ constructor(init: CatalogueApiErrorInit);
23
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,32 @@
1
+ // One error hierarchy over the catalogue API. The degraded (503) signal is
2
+ // never swallowed: it becomes CatalogueUnavailableError, distinct from a
3
+ // legitimate empty result, so a consumer can tell "down" from "empty".
4
+ export class CatalogueApiError extends Error {
5
+ status;
6
+ url;
7
+ body;
8
+ constructor(message, init) {
9
+ super(message);
10
+ this.name = 'CatalogueApiError';
11
+ this.status = init.status;
12
+ this.url = init.url;
13
+ this.body = init.body;
14
+ }
15
+ }
16
+ /** GET /v1/entries/:id → 404: the id is not in the index. */
17
+ export class EntryNotFoundError extends CatalogueApiError {
18
+ constructor(id, init) {
19
+ super(`no catalogue entry with id "${id}"`, init);
20
+ this.name = 'EntryNotFoundError';
21
+ }
22
+ }
23
+ /**
24
+ * The API's explicit 503 degraded response — no configured source has ever
25
+ * ingested successfully. Distinct from a 200 empty/zero-hit result.
26
+ */
27
+ export class CatalogueUnavailableError extends CatalogueApiError {
28
+ constructor(init) {
29
+ super('catalogue unavailable: no source has been ingested successfully yet', init);
30
+ this.name = 'CatalogueUnavailableError';
31
+ }
32
+ }
@@ -0,0 +1,3 @@
1
+ export { CatalogueClient, type CatalogueClientOptions } from './client.js';
2
+ export { CatalogueApiError, CatalogueUnavailableError, EntryNotFoundError, type CatalogueApiErrorInit } from './errors.js';
3
+ export type { ComponentKind, EntryDetail, HotstackCategory, MarketplaceIndex, RegistryResult, SearchHit, SearchPage, SearchParams, SortOrder, SourceStatus, SourceTier, } from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ // @hotstack/catalogue-client — typed, dependency-free read client for the
2
+ // HotStack Catalogue API. See README.md for a quickstart.
3
+ export { CatalogueClient } from './client.js';
4
+ export { CatalogueApiError, CatalogueUnavailableError, EntryNotFoundError } from './errors.js';
@@ -0,0 +1,102 @@
1
+ export type SourceTier = 'curated' | 'community' | 'indexed';
2
+ export type ComponentKind = 'mcp' | 'skills' | 'module' | 'cli';
3
+ export type HotstackCategory = 'code' | 'communication' | 'data' | 'design' | 'development' | 'productivity' | 'sales-marketing';
4
+ export type SortOrder = 'relevance' | 'newest' | 'name';
5
+ /** GET /v1/search query parameters. All optional. */
6
+ export interface SearchParams {
7
+ q?: string;
8
+ category?: HotstackCategory;
9
+ kind?: ComponentKind;
10
+ tier?: SourceTier;
11
+ publisher?: string;
12
+ sort?: SortOrder;
13
+ /** Page size, 1–100 (server default 20). */
14
+ limit?: number;
15
+ /** Page start, ≥ 0 (server default 0). */
16
+ offset?: number;
17
+ }
18
+ export interface SearchHit {
19
+ id: string;
20
+ /** Entry kind: `inline-mcp` | `bundle` | `plugin`. Kept as string; new kinds must not break clients. */
21
+ kind: string;
22
+ tier: SourceTier;
23
+ /** Blended ordering value in [0, 1). */
24
+ rank: number;
25
+ /** Ingest-time ranking score. */
26
+ score: number;
27
+ provenance: {
28
+ sourceId: string;
29
+ ingestedAt: string;
30
+ };
31
+ /** The full native entry (including any inline `mcp` config). Shape varies by kind. */
32
+ entry: unknown;
33
+ }
34
+ export interface SearchPage {
35
+ total: number;
36
+ limit: number;
37
+ offset: number;
38
+ hasMore: boolean;
39
+ results: SearchHit[];
40
+ }
41
+ export interface EntryDetail {
42
+ id: string;
43
+ kind: string;
44
+ tier: SourceTier;
45
+ provenance: {
46
+ sourceId: string;
47
+ ingestedAt: string;
48
+ /** Present only when the id collided across sources. */
49
+ collision?: Record<string, unknown>;
50
+ };
51
+ ranking: {
52
+ score: number;
53
+ signals: Record<string, unknown>;
54
+ };
55
+ entry: unknown;
56
+ }
57
+ /** The GET /v1/registry compat projection (byte-compatible MarketplaceIndex). */
58
+ export interface MarketplaceIndex {
59
+ schemaVersion: number;
60
+ plugins: unknown[];
61
+ }
62
+ export interface SourceStatus {
63
+ id: string;
64
+ tier: SourceTier;
65
+ adapter: string;
66
+ state: 'ok' | 'degraded' | 'never_ingested';
67
+ denylisted: boolean;
68
+ lastAttemptAt: string | null;
69
+ lastSuccessAt: string | null;
70
+ lastError: string | null;
71
+ lastErrorAt: string | null;
72
+ entryCount: number;
73
+ issues: Array<{
74
+ path: string;
75
+ message: string;
76
+ }>;
77
+ collisions: Array<{
78
+ entryId: string;
79
+ role: string;
80
+ winnerSourceId?: string;
81
+ sourceIds: string[];
82
+ }>;
83
+ removals: Array<{
84
+ entryId: string;
85
+ reason: string;
86
+ detail: string;
87
+ removedAt: string;
88
+ }>;
89
+ }
90
+ /**
91
+ * GET /v1/registry outcome. `not_modified` surfaces a real 304 from an ETag
92
+ * round-trip; `ok` carries the strong `etag` and the honest `stale` flag
93
+ * (`x-hotstack-stale`) so a consumer can tell current from last-good data.
94
+ */
95
+ export type RegistryResult = {
96
+ status: 'ok';
97
+ index: MarketplaceIndex;
98
+ etag: string;
99
+ stale: boolean;
100
+ } | {
101
+ status: 'not_modified';
102
+ };
package/dist/types.js ADDED
@@ -0,0 +1,5 @@
1
+ // Types hand-authored from docs/api.md (the API reference is the contract),
2
+ // named to match the service's exported shapes but deliberately NOT imported
3
+ // from service code — the package stands alone. The live contract test keeps
4
+ // them honest against the real /v1 responses.
5
+ export {};
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@hotstack/catalogue-client",
3
+ "version": "0.1.0",
4
+ "description": "Typed, dependency-free fetch client for the HotStack Catalogue read API (search, entries, registry).",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "engines": {
8
+ "node": ">=20"
9
+ },
10
+ "sideEffects": false,
11
+ "main": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc -p tsconfig.json"
24
+ },
25
+ "keywords": [
26
+ "hotstack",
27
+ "mcp",
28
+ "catalogue",
29
+ "model-context-protocol",
30
+ "sdk"
31
+ ]
32
+ }