@ziprack/sdk 0.1.2 → 0.2.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.
@@ -0,0 +1,182 @@
1
+ # Building a zipRACK applet (ziprack/1)
2
+
3
+ You are building an **applet**: a self-contained static web app that a Rack hosts inside a sandboxed
4
+ iframe. The Rack owns login, persistence, AI access, real-time coordination and reporting. The applet
5
+ owns everything the participant sees. It never talks to a database, never calls an API, never holds a
6
+ credential. It asks the Rack over `postMessage` through `@ziprack/sdk`, and the Rack decides, enforces
7
+ and persists.
8
+
9
+ Follow this document exactly. The Rack rejects packages and messages that break it.
10
+
11
+ ## 1. Mental model
12
+
13
+ | Object | What it is |
14
+ |---|---|
15
+ | Applet | One experience or app, identified by an immutable `slug`. Has many versions. |
16
+ | Version | One uploaded zip. Immutable. Exactly one is current. |
17
+ | Attempt | One participant's run (`attempt@1`). Pinned to the version it started on. Holds `status`, `state`, `segment`. |
18
+ | Room | A shared session: an applet plus its participants (`broadcast@1`). Two roles: `participant`, `facilitator`. |
19
+ | Statement | Append-only "actor did verb on activity with result" (`activity@1`). The audit trail and the uniform export. |
20
+ | Record | A mutable row in a declared collection at a declared scope (`db@1`). Working state. |
21
+ | Profile attribute | A Rack-owned fact about the participant, released only if declared and permitted (`auth@1`). |
22
+
23
+ Rules that do not bend:
24
+
25
+ 1. **Declare every capability you call.** `requires.capabilities` is mandatory, not advisory. An undeclared call is refused at runtime with `undeclared_capability`.
26
+ 2. **Nothing persists inside the iframe.** No cookies, no `localStorage`, no `fetch` to the Rack. If it matters, send it through `Rack.*`.
27
+ 3. **Answers are statements, not state.** State is for resuming position. Every committed answer is `Rack.activity.answer()`.
28
+ 4. **Resume must work.** Read `Rack.attempt.state()` and `Rack.attempt.status` at launch and put the participant back where they were.
29
+ 5. **Degrade gracefully.** A requested profile attribute may be withheld. A room may have no facilitator yet. Design for absence.
30
+
31
+ ## 2. Package layout
32
+
33
+ ```
34
+ {slug}-{version}.zip
35
+ ├── manifest.json required, at the zip root
36
+ ├── sbom.json required: CycloneDX (or package-lock.json)
37
+ ├── index.html the entry (configurable via manifest.entry)
38
+ └── assets/… everything the entry references, by RELATIVE path
39
+ ```
40
+
41
+ Installer rules: static files only (`html js mjs css map json wasm txt vtt svg png jpg jpeg gif webp ico woff woff2 ttf mp3 mp4 wav webm ogg pdf`); no `..`, absolute paths or symlinks; dotfiles and `__MACOSX/` skipped; 50 MB default cap; `entry` must exist and end in `.html`; every asset URL relative (`base: './'` in Vite); a `version` uploads once per applet.
42
+
43
+ `npm run package` in the starter builds all of this, including the SBOM from your lockfile.
44
+
45
+ ## 3. manifest.json
46
+
47
+ ```json
48
+ {
49
+ "schema": "ziprack/1",
50
+ "slug": "my-applet",
51
+ "name": "My Applet",
52
+ "version": "1.0.0",
53
+ "entry": "index.html",
54
+ "requires": {
55
+ "capabilities": ["auth@1", "attempt@1", "activity@1"],
56
+ "profile": ["display_name", "locale"]
57
+ },
58
+ "media": [{ "id": "intro", "provider": "youtube", "key": "dQw4w9WgXcQ" }],
59
+ "segments": [],
60
+ "activities": [],
61
+ "reporting": { "metrics": [] }
62
+ }
63
+ ```
64
+
65
+ | Field | Rules |
66
+ |---|---|
67
+ | `schema` | Required, `ziprack/1`. |
68
+ | `slug` | Required. `^[a-z0-9]+(-[a-z0-9]+)*$`, max 100. Immutable. Launch URL `/a/{slug}`. |
69
+ | `name`, `version` | Required. Semver with optional prerelease. |
70
+ | `entry` | Optional, default `index.html`. Relative, ends in `.html`. |
71
+ | `requires.capabilities` | Required (may be empty). Each `id@version`; the Rack must implement every one. |
72
+ | `requires.profile` | Attribute keys, only with `auth@1`. Withheld keys are simply absent. |
73
+ | `media[]` | `provider` in the Rack's allowlist (vimeo, youtube); `key` `^\d+$` (Vimeo) or `^[\w-]{11}$` (YouTube). The Rack builds the URL. |
74
+
75
+ Every other top-level block belongs to a capability and is invalid unless that capability is declared:
76
+
77
+ | Block | Capability | Shape |
78
+ |---|---|---|
79
+ | `segments[]` | `attempt@1` | `{key, label, values[{key,label}]}`; the first segment drives admin filtering |
80
+ | `activities[]`, `reporting.metrics[]` | `activity@1` | as in cn-sim/1: `{id, type, label, scale?, segment?, sequence?}`; metrics pair a pre and post rating and need `attempt@1` |
81
+ | `collections[]` | `db@1` | `{name, scope, schema, indexed?, quota?, permissions?}` |
82
+ | `uploads[]` | `storage@1` | `{name, scope, mime[], max_bytes?, max_files?}` |
83
+ | `prompts[]` | `ai@1` | `{id, mode, template, vars?, output_schema?, budget?}` |
84
+ | `broadcast` | `broadcast@1` | `{channels?, facilitator_events?}` |
85
+
86
+ Scopes (`db@1`, `storage@1`): `attempt` (frozen at completion; needs `attempt@1`), `user` (outlives attempts), `room` (shared; needs `broadcast@1`), `applet` (per version, read-mostly).
87
+
88
+ ## 4. The SDK
89
+
90
+ ```ts
91
+ import { Rack } from '@ziprack/sdk';
92
+
93
+ const launch = await Rack.init({ slug: 'my-applet' }); // mock mode when not inside a Rack
94
+ ```
95
+
96
+ | Namespace | Calls | Notes |
97
+ |---|---|---|
98
+ | `Rack` | `init()`, `on('launch'|'event')`, `mode`, `eject()` | `eject()` needs no capability |
99
+ | `Rack.auth` | `user()`, `attribute(key, fallback)` | Released profile from launch data |
100
+ | `Rack.attempt` | `status`, `state()`, `segment()`, `setState()`, `setSegment()`, `complete()` | State max 64 KB; `complete()` idempotent; after it, state and segment are refused with `completed` |
101
+ | `Rack.activity` | `answer(id, value)`, `progress(stepId)`, `statement({...})` | Verbs `^[a-z][a-z0-9_.:-]*$`; answers put the value in `result.response` |
102
+ | `Rack.db.collection(name)` | `create`, `get`, `update`, `delete`, `list`, `count` | Filters and sorting only on `indexed` paths; 100 per page |
103
+ | `Rack.storage` | `put(upload, file)`, `url(id)`, `list()`, `delete(id)` | Files are addressable only once scanned clean |
104
+ | `Rack.ai` | `ask(promptId, vars)`, `chat(promptId).send(text, onDelta)`, `chat(promptId).reset()` | You supply variables, never instructions |
105
+ | `Rack.broadcast` | `join(channel)` → `{listen, publish, leave}`, `presence()`, `state.get/set`, `claim()`, `transfer(id)`, `on('presence'|'state'|'role'|'status')` | Facilitator-only events are declared in the manifest and enforced by the Rack |
106
+ | `Rack.media` | `embedUrl(id)` | Rack-built player URL |
107
+
108
+ Every call resolves to `{ ok, data?, error?, message? }` and never throws. Error codes: `timeout`,
109
+ `persist_failed`, `undeclared_capability`, `unknown_type`, `validation`, `forbidden`, `completed`,
110
+ `quota_exceeded`, `budget_exceeded`, `not_found`, `conflict`. Do not block the participant on acks;
111
+ `await` only what you must confirm (for example the final answer before `complete()`).
112
+
113
+ ### Mock mode
114
+
115
+ Outside a Rack (`npm run dev`), or if the Rack does not answer within 3 s, the SDK runs a local mock
116
+ that reads `./manifest.json` so declared capabilities, activities, collections and prompts behave as
117
+ they will in production. State and records persist in `localStorage` under `ziprack-mock:{slug}:*`.
118
+ Rooms use `BroadcastChannel`, so two tabs of your dev server form a room. `Rack.mock.ai(promptId, fn)`
119
+ answers prompts yourself; `Rack.mock.resetAttempt()` replays a completed attempt.
120
+
121
+ For tests, `import { FakeHost } from '@ziprack/sdk/testing'` and pass `host.transport()` to
122
+ `Rack.init({ transport })`; then inspect `host.statements`, `host.answers()`, `host.records()`.
123
+
124
+ ## 5. Studio, and its MCP server
125
+
126
+ If the project has an `applet.json` rather than hand-written source, it is a
127
+ [zipRACK Studio](studio.md) applet. That document is the applet: `index.html` and `manifest.json` are
128
+ compiled from it, and the manifest is **derived** from the blocks in the tree, so there is no
129
+ `requires.capabilities` to maintain and nothing that can drift. Editing the compiled output is
130
+ pointless — the next build overwrites it.
131
+
132
+ Studio ships an MCP server, and it is the right way to change such a project:
133
+
134
+ ```sh
135
+ npx ziprack-studio mcp # stdio, run in the project directory
136
+ ```
137
+
138
+ `ziprack_list_blocks` and `ziprack_get_block_schema` tell you what exists and what each block accepts;
139
+ `ziprack_compose` builds structure from a skeleton plus separate content maps; `ziprack_patch` makes
140
+ edits; `ziprack_validate` answers with per-key errors, each carrying the JSON Schema that would have
141
+ been valid. Every mutating tool takes `dry_run`, and every refusal explains itself, so you can converge
142
+ in one round trip instead of guessing.
143
+
144
+ Prefer it to writing `applet.json` directly. The tools go through the same registry, command bus and
145
+ validator as the editor, which is what keeps the result openable by the non-technical colleague who
146
+ will maintain it. When no block fits, write a real module and point a `custom.code` block at it: your
147
+ code runs, and the tree still has a node the editor can show and move.
148
+
149
+ ## 6. Runtime constraints
150
+
151
+ The Rack renders you with `sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"`
152
+ and no `allow-same-origin`: an opaque origin. Cookies, `localStorage`, `IndexedDB` and `fetch` to the
153
+ Rack all fail. `window.open` works. The entry document's CSP is:
154
+
155
+ | Directive | Allowed |
156
+ |---|---|
157
+ | `script-src` | `'self' 'unsafe-inline' 'unsafe-eval' blob:` — bundle every dependency |
158
+ | `style-src`, `font-src` | `'self'` + Bunny/Google fonts |
159
+ | `img-src`, `media-src` | `'self' data: blob:` + the deployment's allowlist. **Not `https:`.** |
160
+ | `connect-src` | `'self'` — no external APIs, no analytics |
161
+ | `frame-src` | Vimeo and YouTube players, via declared `media` only |
162
+
163
+ Ship your images and media inside the zip. A persistent Rack bar sits above the frame and cannot be
164
+ spoofed; never ask participants for credentials inside an applet.
165
+
166
+ ## 7. Publishing
167
+
168
+ Uploads land unpublished. Promoting a version is a deliberate admin action. A new version that adds a
169
+ capability or a profile attribute waits for explicit approval; adding or removing `attempt@1` changes
170
+ the applet from rolling to pinned (or back) and is flagged prominently. Known vulnerabilities from your
171
+ SBOM appear in the review queue. Changing an activity `id` between versions splits export columns;
172
+ changing a `label` only changes headers.
173
+
174
+ ## 8. Pre-upload checklist
175
+
176
+ - [ ] `schema: "ziprack/1"`, version bumped, `requires.capabilities` lists exactly what you call.
177
+ - [ ] `sbom.json` present (`npm run package` does it).
178
+ - [ ] Every question has an `activities[]` entry with a stable `id`, a `type`, a full-sentence `label`.
179
+ - [ ] `setState()` after every transition; refreshing mid-way in mock mode resumes.
180
+ - [ ] `complete()` exactly once; reloading a completed attempt shows the final screen.
181
+ - [ ] All URLs relative, no external `<script>`, no external images or APIs.
182
+ - [ ] Layout works at phone width and leaves the Rack bar alone.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { startDocsMcp } from './server.js';
3
+ await startDocsMcp();
@@ -0,0 +1,31 @@
1
+ export interface DocSection {
2
+ /** The `<a name>` anchor docs link to, or `null` for the text above the first heading. */
3
+ anchor: string | null;
4
+ heading: string;
5
+ text: string;
6
+ }
7
+ export interface Doc {
8
+ /** File name without `.md`, e.g. `sdk`. */
9
+ name: string;
10
+ title: string;
11
+ text: string;
12
+ sections: DocSection[];
13
+ }
14
+ export declare const GUIDELINES = "ziprack-1-authoring";
15
+ /**
16
+ * The docs are copied into `dist/docs/` when the SDK is built, so the set an
17
+ * applet project reads is the set published with the SDK version it installed.
18
+ */
19
+ export declare function loadDocs(directory?: string): Doc[];
20
+ export declare function parseDoc(name: string, text: string): Doc;
21
+ export interface SearchHit {
22
+ doc: Doc;
23
+ section: DocSection;
24
+ score: number;
25
+ }
26
+ /**
27
+ * Plain term frequency, with heading and title hits weighted up and an exact
28
+ * phrase match weighted most. The corpus is a few hundred sections; anything
29
+ * cleverer would be harder to predict than it is worth.
30
+ */
31
+ export declare function searchDocs(docs: Doc[], queries: string[], limit: number): SearchHit[];
@@ -0,0 +1,107 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ export const GUIDELINES = 'ziprack-1-authoring';
4
+ /**
5
+ * The docs are copied into `dist/docs/` when the SDK is built, so the set an
6
+ * applet project reads is the set published with the SDK version it installed.
7
+ */
8
+ export function loadDocs(directory = fileURLToPath(new URL('../docs/', import.meta.url))) {
9
+ const files = readdirSync(directory).filter((file) => file.endsWith('.md'));
10
+ const read = (name) => readFileSync(`${directory}/${name}.md`, 'utf8');
11
+ // docs/README.md is the index: its link order is the reading order.
12
+ const ordered = [];
13
+ if (files.includes('README.md')) {
14
+ for (const match of read('README').matchAll(/\]\(([\w.-]+)\.md\)/g)) {
15
+ if (!ordered.includes(match[1])) {
16
+ ordered.push(match[1]);
17
+ }
18
+ }
19
+ }
20
+ for (const file of files.sort()) {
21
+ const name = file.slice(0, -3);
22
+ if (name !== 'README' && !ordered.includes(name)) {
23
+ ordered.push(name);
24
+ }
25
+ }
26
+ return ordered
27
+ .filter((name) => files.includes(`${name}.md`))
28
+ .map((name) => parseDoc(name, read(name)));
29
+ }
30
+ export function parseDoc(name, text) {
31
+ const lines = text.split('\n');
32
+ const title = lines.find((line) => line.startsWith('# '))?.slice(2).trim() ?? name;
33
+ const sections = [];
34
+ let current = { anchor: null, heading: title, text: '' };
35
+ let buffer = [];
36
+ let pendingAnchor = null;
37
+ let fenced = false;
38
+ const flush = () => {
39
+ current.text = buffer.join('\n').trim();
40
+ if (current.text !== '') {
41
+ sections.push(current);
42
+ }
43
+ };
44
+ for (const line of lines) {
45
+ if (line.startsWith('```')) {
46
+ fenced = !fenced;
47
+ }
48
+ const anchor = fenced ? null : /^<a name="([^"]+)"><\/a>\s*$/.exec(line);
49
+ if (anchor !== null) {
50
+ pendingAnchor = anchor[1];
51
+ continue;
52
+ }
53
+ if (!fenced && line.startsWith('## ')) {
54
+ flush();
55
+ const heading = line.slice(3).trim();
56
+ current = { anchor: pendingAnchor ?? slug(heading), heading, text: '' };
57
+ buffer = [line];
58
+ pendingAnchor = null;
59
+ continue;
60
+ }
61
+ buffer.push(line);
62
+ }
63
+ flush();
64
+ return { name, title, text, sections };
65
+ }
66
+ /**
67
+ * Plain term frequency, with heading and title hits weighted up and an exact
68
+ * phrase match weighted most. The corpus is a few hundred sections; anything
69
+ * cleverer would be harder to predict than it is worth.
70
+ */
71
+ export function searchDocs(docs, queries, limit) {
72
+ const hits = [];
73
+ for (const doc of docs) {
74
+ const title = doc.title.toLowerCase();
75
+ for (const section of doc.sections) {
76
+ const heading = section.heading.toLowerCase();
77
+ const body = section.text.toLowerCase();
78
+ let score = 0;
79
+ for (const query of queries) {
80
+ const phrase = query.trim().toLowerCase();
81
+ const terms = phrase.match(/[a-z0-9_@$.-]+/g)?.map((term) => term.replace(/^[.-]+|[.-]+$/g, '')).filter((term) => term.length > 1) ?? [];
82
+ if (terms.length > 1 && body.includes(phrase)) {
83
+ score += 10;
84
+ }
85
+ for (const term of terms) {
86
+ score += occurrences(body, term) + 5 * occurrences(heading, term) + 3 * occurrences(title, term);
87
+ }
88
+ }
89
+ if (score > 0) {
90
+ hits.push({ doc, section, score });
91
+ }
92
+ }
93
+ }
94
+ return hits.sort((a, b) => b.score - a.score).slice(0, limit);
95
+ }
96
+ function occurrences(haystack, needle) {
97
+ let count = 0;
98
+ let at = haystack.indexOf(needle);
99
+ while (at !== -1) {
100
+ count++;
101
+ at = haystack.indexOf(needle, at + needle.length);
102
+ }
103
+ return count;
104
+ }
105
+ function slug(heading) {
106
+ return heading.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
107
+ }
@@ -0,0 +1,4 @@
1
+ export * from './rpc.js';
2
+ export * from './server.js';
3
+ export * from './tools.js';
4
+ export { loadDocs, searchDocs, type Doc, type DocSection } from './docs.js';
@@ -0,0 +1,4 @@
1
+ export * from './rpc.js';
2
+ export * from './server.js';
3
+ export * from './tools.js';
4
+ export { loadDocs, searchDocs } from './docs.js';
@@ -0,0 +1,33 @@
1
+ import type { Readable, Writable } from 'node:stream';
2
+ /**
3
+ * MCP over stdio is line-delimited JSON-RPC 2.0. That is little enough protocol
4
+ * to own outright, which keeps the studio dependency-free the way the SDK is.
5
+ */
6
+ export interface RpcRequest {
7
+ jsonrpc: '2.0';
8
+ id?: string | number | null;
9
+ method: string;
10
+ params?: Record<string, unknown>;
11
+ }
12
+ export interface RpcError {
13
+ code: number;
14
+ message: string;
15
+ data?: unknown;
16
+ }
17
+ export declare const PARSE_ERROR = -32700;
18
+ export declare const INVALID_REQUEST = -32600;
19
+ export declare const METHOD_NOT_FOUND = -32601;
20
+ export declare const INVALID_PARAMS = -32602;
21
+ export declare const INTERNAL_ERROR = -32603;
22
+ export declare class RpcFailure extends Error {
23
+ readonly code: number;
24
+ readonly data?: unknown | undefined;
25
+ constructor(code: number, message: string, data?: unknown | undefined);
26
+ }
27
+ export type RpcHandler = (params: Record<string, unknown>, method: string) => Promise<unknown> | unknown;
28
+ export interface RpcConnection {
29
+ /** Resolves when the input stream ends. */
30
+ done: Promise<void>;
31
+ notify(method: string, params?: Record<string, unknown>): void;
32
+ }
33
+ export declare function serve(input: Readable, output: Writable, handlers: Map<string, RpcHandler>): RpcConnection;
@@ -0,0 +1,86 @@
1
+ export const PARSE_ERROR = -32700;
2
+ export const INVALID_REQUEST = -32600;
3
+ export const METHOD_NOT_FOUND = -32601;
4
+ export const INVALID_PARAMS = -32602;
5
+ export const INTERNAL_ERROR = -32603;
6
+ export class RpcFailure extends Error {
7
+ code;
8
+ data;
9
+ constructor(code, message, data) {
10
+ super(message);
11
+ this.code = code;
12
+ this.data = data;
13
+ }
14
+ }
15
+ export function serve(input, output, handlers) {
16
+ const write = (payload) => {
17
+ output.write(`${JSON.stringify(payload)}\n`);
18
+ };
19
+ // One request at a time. Tools read and write the document, so overlapping
20
+ // them would let a later call answer from the state before an earlier one's
21
+ // save — the kind of ordering bug an assistant cannot see or work around.
22
+ let queue = Promise.resolve();
23
+ const done = new Promise((resolve) => {
24
+ let buffer = '';
25
+ input.setEncoding('utf8');
26
+ input.on('data', (chunk) => {
27
+ buffer += chunk;
28
+ let newline = buffer.indexOf('\n');
29
+ while (newline !== -1) {
30
+ const line = buffer.slice(0, newline).trim();
31
+ buffer = buffer.slice(newline + 1);
32
+ if (line !== '') {
33
+ queue = queue.then(() => handle(line, handlers, write));
34
+ }
35
+ newline = buffer.indexOf('\n');
36
+ }
37
+ });
38
+ // The queue is drained before the caller is told the connection is over,
39
+ // so a request on the last line still gets its answer.
40
+ input.on('end', () => void queue.then(resolve));
41
+ input.on('close', () => void queue.then(resolve));
42
+ });
43
+ return {
44
+ done,
45
+ notify: (method, params) => write({ jsonrpc: '2.0', method, params: params ?? {} }),
46
+ };
47
+ }
48
+ async function handle(line, handlers, write) {
49
+ let request;
50
+ try {
51
+ request = JSON.parse(line);
52
+ }
53
+ catch {
54
+ write({ jsonrpc: '2.0', id: null, error: { code: PARSE_ERROR, message: 'Invalid JSON.' } });
55
+ return;
56
+ }
57
+ if (typeof request.method !== 'string') {
58
+ write({ jsonrpc: '2.0', id: request.id ?? null, error: { code: INVALID_REQUEST, message: 'Missing method.' } });
59
+ return;
60
+ }
61
+ const handler = handlers.get(request.method);
62
+ // A notification has no id and, per JSON-RPC, gets no answer at all —
63
+ // including for `notifications/initialized`, which every client sends.
64
+ const isNotification = request.id === undefined || request.id === null;
65
+ if (handler === undefined) {
66
+ if (!isNotification) {
67
+ write({ jsonrpc: '2.0', id: request.id, error: { code: METHOD_NOT_FOUND, message: `Unknown method [${request.method}].` } });
68
+ }
69
+ return;
70
+ }
71
+ try {
72
+ const result = await handler(request.params ?? {}, request.method);
73
+ if (!isNotification) {
74
+ write({ jsonrpc: '2.0', id: request.id, result });
75
+ }
76
+ }
77
+ catch (error) {
78
+ if (isNotification) {
79
+ return;
80
+ }
81
+ const failure = error instanceof RpcFailure
82
+ ? { code: error.code, message: error.message, data: error.data }
83
+ : { code: INTERNAL_ERROR, message: error instanceof Error ? error.message : String(error) };
84
+ write({ jsonrpc: '2.0', id: request.id, error: failure });
85
+ }
86
+ }
@@ -0,0 +1,23 @@
1
+ import { type RpcHandler } from './rpc.js';
2
+ import { type ToolDefinition } from './tools.js';
3
+ export declare const PROTOCOL_VERSION = "2024-11-05";
4
+ export interface ServerInfo {
5
+ name: string;
6
+ version: string;
7
+ instructions: string;
8
+ }
9
+ export declare const DOCS_INSTRUCTIONS = "zipRACK applet docs, matched to the installed @ziprack/sdk. Call ziprack_guidelines before writing or changing applet code, and ziprack_search_docs before guessing at an SDK method, manifest field or error code.";
10
+ /** Wires a tool set onto the MCP method surface a client expects. */
11
+ export declare function session(tools: ToolDefinition[], info: ServerInfo): Map<string, RpcHandler>;
12
+ /**
13
+ * Tool results are text content blocks. Structured payloads go through as
14
+ * indented JSON, which is what an assistant can actually read back.
15
+ */
16
+ export declare function content(payload: unknown): {
17
+ content: Array<{
18
+ type: 'text';
19
+ text: string;
20
+ }>;
21
+ isError?: boolean;
22
+ };
23
+ export declare function startDocsMcp(): Promise<void>;
@@ -0,0 +1,55 @@
1
+ import { INVALID_PARAMS, RpcFailure, serve } from './rpc.js';
2
+ import { SDK_VERSION, docsTools } from './tools.js';
3
+ export const PROTOCOL_VERSION = '2024-11-05';
4
+ export const DOCS_INSTRUCTIONS = 'zipRACK applet docs, matched to the installed @ziprack/sdk. Call ziprack_guidelines before writing or changing applet code, and ziprack_search_docs before guessing at an SDK method, manifest field or error code.';
5
+ /** Wires a tool set onto the MCP method surface a client expects. */
6
+ export function session(tools, info) {
7
+ const byName = new Map(tools.map((tool) => [tool.name, tool]));
8
+ const handlers = new Map();
9
+ handlers.set('initialize', () => ({
10
+ protocolVersion: PROTOCOL_VERSION,
11
+ capabilities: { tools: { listChanged: false } },
12
+ serverInfo: { name: info.name, version: info.version },
13
+ instructions: info.instructions,
14
+ }));
15
+ handlers.set('notifications/initialized', () => null);
16
+ handlers.set('ping', () => ({}));
17
+ handlers.set('tools/list', () => ({
18
+ tools: tools.map((tool) => ({
19
+ name: tool.name,
20
+ description: tool.description,
21
+ inputSchema: tool.inputSchema,
22
+ })),
23
+ }));
24
+ handlers.set('tools/call', async (params) => {
25
+ const name = params.name;
26
+ if (typeof name !== 'string') {
27
+ throw new RpcFailure(INVALID_PARAMS, 'tools/call needs a tool name.');
28
+ }
29
+ const tool = byName.get(name);
30
+ if (tool === undefined) {
31
+ throw new RpcFailure(INVALID_PARAMS, `Unknown tool [${name}].`);
32
+ }
33
+ const args = params.arguments;
34
+ return content(await tool.run(args === null || typeof args !== 'object' ? {} : args));
35
+ });
36
+ return handlers;
37
+ }
38
+ /**
39
+ * Tool results are text content blocks. Structured payloads go through as
40
+ * indented JSON, which is what an assistant can actually read back.
41
+ */
42
+ export function content(payload) {
43
+ if (typeof payload === 'string') {
44
+ return { content: [{ type: 'text', text: payload }] };
45
+ }
46
+ if (payload !== null && typeof payload === 'object' && 'isError' in payload) {
47
+ const { isError, ...rest } = payload;
48
+ return { content: [{ type: 'text', text: JSON.stringify(rest, null, 2) }], isError };
49
+ }
50
+ return { content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] };
51
+ }
52
+ export async function startDocsMcp() {
53
+ const handlers = session(docsTools(), { name: 'ziprack', version: SDK_VERSION, instructions: DOCS_INSTRUCTIONS });
54
+ await serve(process.stdin, process.stdout, handlers).done;
55
+ }
@@ -0,0 +1,20 @@
1
+ import { type Doc } from './docs.js';
2
+ export interface ToolDefinition {
3
+ name: string;
4
+ description: string;
5
+ inputSchema: {
6
+ type: 'object';
7
+ properties?: Record<string, object>;
8
+ required?: string[];
9
+ additionalProperties?: boolean;
10
+ };
11
+ /** Returned as the tool result; objects are serialised as JSON text. */
12
+ run(args: Record<string, unknown>): Promise<unknown> | unknown;
13
+ }
14
+ export declare const SDK_VERSION: string;
15
+ /**
16
+ * Docs and guidelines for the SDK version this server was installed with.
17
+ * Every answer names that version, so an assistant never mistakes it for
18
+ * whatever it remembers of an older one.
19
+ */
20
+ export declare function docsTools(docs?: Doc[]): ToolDefinition[];