@holokai/harbor-sdk 0.3.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,72 @@
1
+ # @holokai/harbor-sdk
2
+
3
+ The harbor extension to [MCP Apps](https://github.com/modelcontextprotocol/ext-apps): what a hosted
4
+ view can ask harbor for beyond the MCP Apps spec, spelled once.
5
+
6
+ Two entry points:
7
+
8
+ - `@holokai/harbor-sdk/contract` — the public wire. Zero imports. Method names, params and result
9
+ types, a JSON Schema per method's params, the event map, the grant map, and the shape harbor
10
+ advertises under `hostCapabilities.experimental['ai.holokai/harbor']`. Harbor's own gate imports
11
+ this module for validation, so the SDK's types cannot drift from what the gate enforces.
12
+ - `@holokai/harbor-sdk` — `connectHarbor(app)`: a thin layer OVER the ext-apps `App` instance
13
+ (never a second JSON-RPC client on the same channel). Returns `null` in any host that is not
14
+ harbor, so the same app runs unchanged in Claude, ChatGPT, VS Code or Goose as a plain MCP App.
15
+
16
+ ```ts
17
+ import {App} from '@modelcontextprotocol/ext-apps';
18
+ import {connectHarbor} from '@holokai/harbor-sdk';
19
+
20
+ const app = new App({name: 'my-app', version: '1.0.0'});
21
+ await app.connect();
22
+ const harbor = connectHarbor(app);
23
+ if (harbor) {
24
+ const {workflows} = await harbor.workflows.list({limit: 20});
25
+ const off = harbor.events.on('run.changed', ({workflow}) => console.log(workflow.status));
26
+ }
27
+ ```
28
+
29
+ Opening a file: `harbor.files.open({name, tool, arguments})` asks harbor to open a file the app
30
+ identifies. Harbor calls `tool` on the app's own server; the tool answers a `CallToolResult` whose
31
+ content holds exactly one `{type: 'resource', resource: {uri, mimeType?, blob}}` carrying the bytes
32
+ base64. Harbor keeps a read-only copy named `name` and opens it in its own editor; the answer is
33
+ `{name}`. Needs the `files.open` grant.
34
+
35
+ Opening a link: `harbor.browser.open({url})` asks harbor to open a URL in the person's own browser.
36
+ A view's frame is sandboxed without `allow-popups`, so a link of its own goes nowhere; this is the
37
+ way out. **https only** — harbor parses the URL in its main process and refuses every other scheme
38
+ with `-32602`, so `http:`, `file:`, `javascript:` and an application's own scheme are all dead
39
+ regardless of what the page checks. The answer is `{url}` as harbor parsed it. Needs the
40
+ `browser.open` grant.
41
+
42
+ Saying what the page is showing: `reportView(app, {state, names, text})` sends one WHOLE snapshot of
43
+ the view on the spec's own `ui/update-model-context`, and `savedView(app)` answers what harbor saved
44
+ for this page last time. Neither goes through `connectHarbor`: `reportView` works under any MCP Apps
45
+ host, and `savedView` answers `null` under every host but harbor. Call both after `app.connect()`.
46
+
47
+ - **The advertisement is the disclosure.** Harbor answers `ui/initialize` with
48
+ `hostCapabilities.updateModelContext` naming the modalities it takes, and `reportView` sends only
49
+ those. A host that advertises no `structuredContent` is sent the text block and neither harbor
50
+ key, so a pane width and a subject line never enter a foreign host's context; a host that
51
+ advertises nothing is sent nothing at all.
52
+ - **`view.state`** admits the structured view — `{place, item, filter, query, panes}`, harbor's key
53
+ under `structuredContent`. Harbor remembers it and hands it back; it goes nowhere else.
54
+ - **`view.content`** admits what the page says in its own words (the `content` blocks) and the two
55
+ names it offers for what it has open (`{item, place}`, its own key under `structuredContent`).
56
+ Harbor holds these only while the view is mounted.
57
+ - **Refused:** a `content` block that is not text — harbor advertises no other modality, so an
58
+ image, audio, a resource or a resource link is a `-32602` rather than a dropped block. So is a
59
+ structured half that is present but malformed: an unknown field, a string over 256 characters, a
60
+ `panes` bag over 8 entries, a pane name that is not a short identifier, or a pane width that is
61
+ not a finite number. **Not refused, simply not kept:** a half that is absent or ungranted, a key
62
+ belonging to another extension, a name over 120 characters or empty once its control characters
63
+ are gone, and text over the result budget — one unusable half never costs the report the other.
64
+ - **The saved view arrives on the `ui/initialize` ANSWER**, under harbor's key in `hostContext`, so
65
+ a page reads it before its first frame. Harbor never pushes it again.
66
+
67
+ What harbor does with each half, and why the two never cross, is `docs/design/hosted-apps.md`,
68
+ section 9.
69
+
70
+ Versioning: the extension object carries `version`; methods are additive within a major. Events are
71
+ invalidation signals: a subscription's `seq` increments per delivered event, and a gap means "re-read
72
+ a snapshot" — there is no replay.
@@ -0,0 +1,319 @@
1
+ import {createRequire as __harborCreateRequire} from 'node:module'; const require = __harborCreateRequire(import.meta.url);
2
+
3
+ // src/contract.ts
4
+ var HARBOR_EXTENSION_ID = "ai.holokai/harbor";
5
+ var HARBOR_PAGE_READER = { tool: "read_page", reads: "page" };
6
+ var HARBOR_EXTENSION_VERSION = "0.2";
7
+ var MCP_APPS_PROTOCOL_VERSION = "2026-01-26";
8
+ var HARBOR_METHODS = {
9
+ workflowsList: "harbor/workflows/list",
10
+ workflowsSubmit: "harbor/workflows/submit",
11
+ workflowsGet: "harbor/workflows/get",
12
+ capabilitiesList: "harbor/capabilities/list",
13
+ eventsSubscribe: "harbor/events/subscribe",
14
+ eventsUnsubscribe: "harbor/events/unsubscribe",
15
+ filesOpen: "harbor/files/open",
16
+ filesAdd: "harbor/files/add",
17
+ filesLink: "harbor/files/link",
18
+ browserOpen: "harbor/browser/open",
19
+ barSet: "harbor/bar/set",
20
+ viewsOpen: "harbor/views/open",
21
+ viewStatus: "harbor/view/status"
22
+ };
23
+ var HARBOR_EVENT_NOTIFICATION = "harbor/notifications/event";
24
+ var HARBOR_BAR_NOTIFICATION = "harbor/notifications/bar";
25
+ var WORKFLOWS_LIST_LIMIT = { min: 1, max: 50, default: 20 };
26
+ var WORKFLOW_TASKS_MAX = 50;
27
+ var BAR_LIMITS = {
28
+ placeholderChars: 60,
29
+ labelChars: 60,
30
+ valueChars: 500,
31
+ nameChars: 80,
32
+ items: 8,
33
+ itemLabelChars: 60,
34
+ itemIdChars: 40
35
+ };
36
+ var VIEW_STATUSES = ["idle", "running", "waiting", "completed", "failed"];
37
+ var HARBOR_VIEW_STATE_LIMITS = {
38
+ /** Longest a `place`, `item`, `filter` or `query` may be. */
39
+ valueChars: 256,
40
+ /** Most entries `panes` may carry. */
41
+ paneEntries: 8,
42
+ /** Longest a pane name may be. */
43
+ paneNameChars: 32
44
+ };
45
+ var PANE_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
46
+ var HARBOR_VIEW_STATE_STRING_KEYS = ["place", "item", "filter", "query"];
47
+ function validateViewState(value) {
48
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
49
+ return { ok: false, message: "the view state must be an object" };
50
+ }
51
+ const record = value;
52
+ const state = {};
53
+ for (const key of Object.keys(record)) {
54
+ const known = key === "panes" || HARBOR_VIEW_STATE_STRING_KEYS.includes(key);
55
+ if (!known) return { ok: false, message: `field '${key}' is not part of the view state` };
56
+ }
57
+ for (const key of HARBOR_VIEW_STATE_STRING_KEYS) {
58
+ const field = record[key];
59
+ if (field === void 0) continue;
60
+ if (typeof field !== "string") return { ok: false, message: `field '${key}' must be a string` };
61
+ if (field.length > HARBOR_VIEW_STATE_LIMITS.valueChars) {
62
+ return { ok: false, message: `field '${key}' is longer than ${HARBOR_VIEW_STATE_LIMITS.valueChars} chars` };
63
+ }
64
+ state[key] = field;
65
+ }
66
+ const panes = record["panes"];
67
+ if (panes !== void 0) {
68
+ if (typeof panes !== "object" || panes === null || Array.isArray(panes)) {
69
+ return { ok: false, message: "panes must be an object" };
70
+ }
71
+ const entries = Object.entries(panes);
72
+ if (entries.length > HARBOR_VIEW_STATE_LIMITS.paneEntries) {
73
+ return { ok: false, message: `panes takes at most ${HARBOR_VIEW_STATE_LIMITS.paneEntries} entries` };
74
+ }
75
+ const kept = {};
76
+ for (const [name, width] of entries) {
77
+ if (name.length > HARBOR_VIEW_STATE_LIMITS.paneNameChars || !PANE_NAME_PATTERN.test(name)) {
78
+ return { ok: false, message: "a pane name must be a short identifier" };
79
+ }
80
+ if (typeof width !== "number" || !Number.isFinite(width)) {
81
+ return { ok: false, message: "a pane width must be a finite number" };
82
+ }
83
+ kept[name] = width;
84
+ }
85
+ state.panes = kept;
86
+ }
87
+ return { ok: true, value: state };
88
+ }
89
+ var HARBOR_VIEW_NAMES_ID = "ai.holokai/harbor/names";
90
+ var HARBOR_VIEW_NAME_LIMITS = {
91
+ /** Longest an `item`, `place` or step name may be; a longer one is not kept. */
92
+ nameChars: 120,
93
+ /** Most steps a trail may hold; past it, the first step and the innermost ones are kept. */
94
+ trailSteps: 12,
95
+ /** Longest a step's id may be; a step with a longer one, or one holding a control character, is not kept. */
96
+ stepIdChars: 256
97
+ };
98
+ var VIEW_NAME_KEYS = ["item", "place"];
99
+ var VIEW_NAMES_FIELDS = [...VIEW_NAME_KEYS, "trail"];
100
+ var CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
101
+ function validateViewNames(value) {
102
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
103
+ return { ok: false, message: "the view names must be an object" };
104
+ }
105
+ const record = value;
106
+ const names = {};
107
+ for (const key of Object.keys(record)) {
108
+ if (!VIEW_NAMES_FIELDS.includes(key)) {
109
+ return { ok: false, message: `field '${key}' is not part of the view names` };
110
+ }
111
+ }
112
+ for (const key of VIEW_NAME_KEYS) {
113
+ const field = record[key];
114
+ if (field === void 0) continue;
115
+ if (typeof field !== "string") return { ok: false, message: `field '${key}' must be a string` };
116
+ const clean = cleanName(field);
117
+ if (clean !== null) names[key] = clean;
118
+ }
119
+ const trail = record["trail"];
120
+ if (trail !== void 0) {
121
+ if (!Array.isArray(trail)) return { ok: false, message: `field 'trail' must be an array` };
122
+ const steps = [];
123
+ const cap = HARBOR_VIEW_NAME_LIMITS.trailSteps;
124
+ const kept = trail.length <= cap ? trail : [trail[0], ...trail.slice(trail.length - (cap - 1))];
125
+ for (const step of kept) {
126
+ if (typeof step !== "object" || step === null || Array.isArray(step)) {
127
+ return { ok: false, message: `a trail step must be an object` };
128
+ }
129
+ const { id, name } = step;
130
+ if (typeof id !== "string" || typeof name !== "string") {
131
+ return { ok: false, message: `a trail step must have a string 'id' and 'name'` };
132
+ }
133
+ const cleanStepName = cleanName(name);
134
+ if (id.length === 0 || id.length > HARBOR_VIEW_NAME_LIMITS.stepIdChars || id.replace(CONTROL_CHARS, "") !== id || cleanStepName === null) {
135
+ continue;
136
+ }
137
+ steps.push({ id, name: cleanStepName });
138
+ }
139
+ if (steps.length > 0) names.trail = steps;
140
+ }
141
+ return { ok: true, value: names };
142
+ }
143
+ function cleanName(field) {
144
+ const clean = field.replace(CONTROL_CHARS, " ").trim();
145
+ return clean.length === 0 || clean.length > HARBOR_VIEW_NAME_LIMITS.nameChars ? null : clean;
146
+ }
147
+ var UPDATE_MODEL_CONTEXT_PARAMS_SCHEMA = {
148
+ type: "object",
149
+ properties: {
150
+ content: { type: "array", description: "Context blocks for the model; harbor takes text blocks only." },
151
+ structuredContent: { type: "object", description: "Machine-readable context, keyed by extension id." },
152
+ _meta: { type: "object" }
153
+ }
154
+ };
155
+ var HARBOR_EVENT_NAMES = [
156
+ "harbor.heartbeat",
157
+ "run.changed",
158
+ "workflow.task",
159
+ "workflow.changed",
160
+ "server.notification"
161
+ ];
162
+ var HARBOR_METHOD_PARAMS_SCHEMA = {
163
+ "harbor/workflows/list": {
164
+ type: "object",
165
+ properties: { limit: { type: "integer", description: "How many, newest first. 1..50, default 20." } }
166
+ },
167
+ "harbor/workflows/submit": {
168
+ type: "object",
169
+ properties: {
170
+ name: { type: "string", description: "A human label for the run; the server assigns the id." },
171
+ description: { type: "string" },
172
+ tasks: { type: "array", description: "The DAG, as WorkflowTaskSpec rows; validated in depth by harbor." }
173
+ },
174
+ required: ["name", "tasks"]
175
+ },
176
+ "harbor/workflows/get": {
177
+ type: "object",
178
+ properties: { workflowId: { type: "string" } },
179
+ required: ["workflowId"]
180
+ },
181
+ // No params: an empty properties map means any key is refused.
182
+ "harbor/capabilities/list": { type: "object", properties: {} },
183
+ "harbor/events/subscribe": {
184
+ type: "object",
185
+ properties: { events: { type: "array", description: "Event names to receive; each must be a known name." } },
186
+ required: ["events"]
187
+ },
188
+ "harbor/events/unsubscribe": {
189
+ type: "object",
190
+ properties: { subscriptionId: { type: "string" } },
191
+ required: ["subscriptionId"]
192
+ },
193
+ "harbor/files/open": {
194
+ type: "object",
195
+ properties: {
196
+ name: { type: "string", description: "The file name to open the copy under; a bare name, no folders." },
197
+ tool: { type: "string", description: "The app's own tool that returns the file's bytes." },
198
+ arguments: { type: "object", description: "Arguments for that tool." }
199
+ },
200
+ required: ["name", "tool"]
201
+ },
202
+ "harbor/files/add": {
203
+ type: "object",
204
+ properties: {
205
+ name: { type: "string", description: "The file name to save it under; a bare name, no folders." },
206
+ tool: { type: "string", description: "The app's own tool that returns the file's bytes." },
207
+ arguments: { type: "object", description: "Arguments for that tool." }
208
+ },
209
+ required: ["name", "tool"]
210
+ },
211
+ "harbor/files/link": {
212
+ type: "object",
213
+ properties: {
214
+ id: { type: "string", description: "A folder or file id this app's own tools gave." },
215
+ url: { type: "string", description: "The folder's address on its provider's own site." }
216
+ },
217
+ required: ["id"]
218
+ },
219
+ "harbor/browser/open": {
220
+ type: "object",
221
+ // `_meta` is the one key MCP allows on ANY request's params (a progress
222
+ // token rides it), and the validator refuses what a schema does not
223
+ // name — so a client that attaches one would be told its field is not
224
+ // allowed. `tools/call` names it for the same reason.
225
+ properties: {
226
+ url: { type: "string", description: "An https URL to open in the person\u2019s own browser." },
227
+ _meta: { type: "object" }
228
+ },
229
+ required: ["url"]
230
+ },
231
+ "harbor/bar/set": {
232
+ type: "object",
233
+ properties: {
234
+ search: {
235
+ type: "object",
236
+ description: "The search field, or null for none; its shape and caps are checked by harbor."
237
+ },
238
+ account: {
239
+ type: "object",
240
+ description: "The account menu, or null for none; its shape and caps are checked by harbor."
241
+ },
242
+ _meta: { type: "object" }
243
+ }
244
+ },
245
+ "harbor/views/open": {
246
+ type: "object",
247
+ properties: {
248
+ title: { type: "string", description: "What the new window is called until its page names what it shows." },
249
+ view: { type: "object", description: "The saved view the new page boots with; a HarborViewState." },
250
+ _meta: { type: "object" }
251
+ },
252
+ required: ["title", "view"]
253
+ },
254
+ "harbor/view/status": {
255
+ type: "object",
256
+ properties: {
257
+ status: {
258
+ type: "string",
259
+ enum: VIEW_STATUSES,
260
+ description: "What the page's tray button shows: idle, running, waiting, completed or failed."
261
+ },
262
+ _meta: { type: "object" }
263
+ },
264
+ required: ["status"]
265
+ }
266
+ };
267
+ var HARBOR_GRANT_OF = {
268
+ "harbor/workflows/list": "workflows.read",
269
+ "harbor/workflows/submit": "workflows.submit",
270
+ "harbor/workflows/get": "workflows.submit",
271
+ "harbor/capabilities/list": "capabilities.read",
272
+ "harbor/events/subscribe": "by-params",
273
+ "harbor/events/unsubscribe": "by-params",
274
+ "harbor/files/open": "files.open",
275
+ "harbor/files/add": "files.add",
276
+ "harbor/files/link": "files.add",
277
+ "harbor/browser/open": "browser.open",
278
+ "harbor/bar/set": "bar.set",
279
+ "harbor/views/open": "views.open",
280
+ "harbor/view/status": "view.state",
281
+ "harbor.heartbeat": "events.diagnostic",
282
+ "run.changed": "events.runs",
283
+ "workflow.task": "workflows.submit",
284
+ "workflow.changed": "workflows.submit",
285
+ "server.notification": "server.tools"
286
+ };
287
+ var STANDARD_METHOD_GRANT_OF = {
288
+ "tools/call": "server.tools",
289
+ "resources/list": "server.resources",
290
+ "resources/read": "server.resources",
291
+ "ui/update-model-context": ["view.state", "view.content"]
292
+ };
293
+ var WORKFLOW_TITLE_GRANT = "workflows.content";
294
+
295
+ export {
296
+ HARBOR_EXTENSION_ID,
297
+ HARBOR_PAGE_READER,
298
+ HARBOR_EXTENSION_VERSION,
299
+ MCP_APPS_PROTOCOL_VERSION,
300
+ HARBOR_METHODS,
301
+ HARBOR_EVENT_NOTIFICATION,
302
+ HARBOR_BAR_NOTIFICATION,
303
+ WORKFLOWS_LIST_LIMIT,
304
+ WORKFLOW_TASKS_MAX,
305
+ BAR_LIMITS,
306
+ VIEW_STATUSES,
307
+ HARBOR_VIEW_STATE_LIMITS,
308
+ HARBOR_VIEW_STATE_STRING_KEYS,
309
+ validateViewState,
310
+ HARBOR_VIEW_NAMES_ID,
311
+ HARBOR_VIEW_NAME_LIMITS,
312
+ validateViewNames,
313
+ UPDATE_MODEL_CONTEXT_PARAMS_SCHEMA,
314
+ HARBOR_EVENT_NAMES,
315
+ HARBOR_METHOD_PARAMS_SCHEMA,
316
+ HARBOR_GRANT_OF,
317
+ STANDARD_METHOD_GRANT_OF,
318
+ WORKFLOW_TITLE_GRANT
319
+ };