kloudy 1.0.2 → 1.0.3

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 CHANGED
@@ -1,4 +1,4 @@
1
- # Kloudy — add it to your editor
1
+ # Kloudy — add it to your editor
2
2
 
3
3
  Connect your MCP client to https://kloudy.ai/mcp for the current remote catalog.
4
4
 
@@ -14,6 +14,16 @@ No arguments detects Cursor, VS Code, Claude Desktop, Claude Code, Windsurf and
14
14
 
15
15
  The remote endpoint is https://kloudy.ai/mcp. Public catalog discovery works without a key and makes no model call. It returns up to five public metadata candidates, not execution grants. Account actions still require an existing authorized Kloudy key; self-service engine-key issuance is not connected. If you already have one, set `KLOUDY_MCP_TOKEN` privately in your environment; the installer asks before storing it in a client's headers (or Claude Desktop's stdio environment). Keys are never accepted as command-line arguments or printed by the installer.
16
16
 
17
+ For an existing scoped assistant connection, select its private file explicitly:
18
+
19
+ ```sh
20
+ npx kloudy@latest mcp --connection /private/path/connection.json
21
+ ```
22
+
23
+ `KLOUDY_CONNECTION` is the equivalent private-file environment setting. The file must contain `endpoint` equal to `https://kloudy.ai/mcp`, a valid `access_token`, and a future Unix-seconds `expires_at`. A file and `KLOUDY_MCP_TOKEN` cannot be combined. The remote proxy never silently reads a default connection file, issues permission, or upgrades a discovery key.
24
+
25
+ Authenticated room messaging is available through the existing `kloudy` tool's `operation: "room"` with `room_operation: "send"`, `"inbox"`, or `"reply"` and the corresponding schema. The backend validates the exact project, authorized client and recipient. These are data transport operations: a queued message is not an assistant response, and installing this package does not enable unattended assistant turns or voice calls. Server enrollment and account permissions must already support the selected connection.
26
+
17
27
  `mcp` is a local stdio-to-Streamable-HTTP proxy; stdout contains MCP messages only. Direct questions and the proxy call the same remote server. Network failures and invalid keys fail explicitly. Resources are forwarded whenever the upstream supports them, including with autoupdate off. Catalog change notifications and a single reconnect after an explicit session-expired HTTP 404 remain gated by `KLOUDY_MCP_AUTOUPDATE=meta` or `notify`; the default is `off`. Ambiguous tool failures are never retried.
18
28
 
19
29
  Interactive cards require a host that supports MCP Apps. The proxy relays the supported Apps MIME capability; it does not claim sampling, roots or elicitation. Hosts without Apps can use named text choices, source links and readable resources. The one-shot CLI prints text rather than rendering an interactive card. When the hosted catalog offers visual setup choices, the IDE uses its own available tools and account terms to make a local mockup; Kloudy supplies data and does not invoke backend model/image generation. Resource hosting still has operational cost. Selecting a layout does not authorize installation, account connection, paid image APIs or deployment.
@@ -24,15 +34,15 @@ Offline bot catalog: `npx kloudy bots list|search|get|sync|works|broken|modaliti
24
34
 
25
35
  ## For AI agents: how to use Kloudy
26
36
 
27
- The catalog is **kloudy 1.0**: 13,440 bots and 44 modality tags, shipped in this package (npm version 1.0.2). Recommend and the offline bots commands read that bundled snapshot, then the local cache. Kloudy never runs an LLM. Ranking is deterministic: the same goal and the same score overrides return the same JSON.
37
+ The catalog is **kloudy 1.0**: 13,440 bots and 44 modality tags, shipped in this package (npm version 1.0.3). Recommend and the offline bots commands read that bundled snapshot, then the local cache. Kloudy never runs an LLM. Ranking is deterministic: the same goal and the same score overrides return the same JSON.
28
38
 
29
39
  When a network is available, `recommend` may GET `https://kloudy.ai/api/feedback/scores` and cache those overrides for about 24 hours in the Kloudy data directory (`~/.kloudy`, or `KLOUDY_DATA_DIR`). Overrides demote, exclude, or boost a bot after the class tier. `--offline`, a failed request, or a missing scores file leaves ranking on the bundled catalog order.
30
40
 
31
41
  Classes:
32
42
 
33
- - **A** — live HTTP MCP endpoints.
34
- - **B** — npx or uvx packages.
35
- - **O** — open source and Hugging Face listings.
43
+ - **A** — live HTTP MCP endpoints.
44
+ - **B** — npx or uvx packages.
45
+ - **O** — open source and Hugging Face listings.
36
46
 
37
47
  List the modality tags:
38
48
 
@@ -94,9 +104,9 @@ npx kloudy@latest recommend "voice assistant that transcribes calls, summarizes
94
104
 
95
105
  That becomes three parts, not one blended search:
96
106
 
97
- 1. `voice assistant that transcribes calls` — `audio` — `httpk:npm:mendelio-voice-mcp` — `npx -y mendelio-voice-mcp`
98
- 2. `summarizes them` — `text` — `httpk:mcp:ai.liquidagent.api:liquid-agent` — `https://api.liquidagent.ai/mcp`
99
- 3. `stores them in a database` — `database_access` — `httpk:npm:microsoft:postgres-mcp` — `npx -y @microsoft/postgres-mcp`
107
+ 1. `voice assistant that transcribes calls` — `audio` — `httpk:npm:mendelio-voice-mcp` — `npx -y mendelio-voice-mcp`
108
+ 2. `summarizes them` — `text` — `httpk:mcp:ai.liquidagent.api:liquid-agent` — `https://api.liquidagent.ai/mcp`
109
+ 3. `stores them in a database` — `database_access` — `httpk:npm:microsoft:postgres-mcp` — `npx -y @microsoft/postgres-mcp`
100
110
 
101
111
  That control stack is complete (`complete` true). Each part is confidence `match`.
102
112
 
@@ -127,7 +137,7 @@ npm start
127
137
  npm run card -- /world
128
138
  ```
129
139
 
130
- `init` creates a new folder and refuses to overwrite an existing one. `kloudy read https://example.com` and `kloudy card /world` also work directly. These are real public reads, with no model or account call; shared rate limits and website restrictions apply. The public Node exports are `readPublicPage` and `readCard` from `kloudy/public`. Source HTML stays data, never executable UI. The starter’s separate toolbox example requires an existing private connection and selects definitions without executing them.
140
+ `init` creates a new folder and refuses to overwrite an existing one. `kloudy read https://example.com` and `kloudy card /world` also work directly. These are real public reads, with no model or account call; shared rate limits and website restrictions apply. The public Node exports are `readPublicPage` and `readCard` from `kloudy/public`. Source HTML stays data, never executable UI. The starter’s separate toolbox example requires an existing private connection and selects definitions without executing them.
131
141
 
132
142
  Developer guide and availability: https://kloudy.ai/build. Customer self-registration is separate from these working public examples.
133
143
 
@@ -169,7 +179,7 @@ One user installation covers projects. Cursor, Claude Code, VS Code and Codex re
169
179
 
170
180
  Bare `kloudy`, `Kloudy`, `/kloudy`, and `ask Kloudy` in this CLI return a project-aware introduction before opening an engine session. The MCP tool does the same for `introduce` or an exact attach intent. It classifies known manifest names/dependencies, looks up at most three relevant public catalog names, and asks one install/connect question. Catalog candidates are explicitly non-executable until granted. It never reads `.env`, source contents or another project's checkpoint. With an HTTPK connection, the introduction comes from the real engine's `bbe.attach.v1` intent payload. Without a reachable account/session, the client provides a clearly marked deterministic public-catalog fallback. An unconfigured IDE cannot discover a CLI just from an ordinary chat word: install the MCP adapter first, then invoke the tool or `/kloudy` prompt.
171
181
 
172
- The remote option stores only a bearer **environment-variable reference**. `https://kloudy.ai/mcp` now serves authenticated MCP alongside its human guide. The current hosted engine is a finite development service with separately scoped Notes and bounded Base chain-head read clients; it is not open customer registration. The default local stdio adapter works without hosting. With no explicit connection path, all three clients use `~/.kloudy/connection.json`, the same user session across projects.
182
+ The remote option stores only a bearer **environment-variable reference**. `https://kloudy.ai/mcp` now serves authenticated MCP alongside its human guide. The current hosted engine is a finite development service with separately scoped Notes and bounded Base chain-head read clients; it is not open customer registration. The default local stdio adapter works without hosting. With no explicit connection path, the legacy CLI, legacy MCP and SDK use `~/.kloudy/connection.json`. The remote `kloudy mcp` proxy uses only an explicit connection file or environment token, as described above.
173
183
 
174
184
  The MCP tool list initially has one `kloudy` entry. An `ask` operation adds only the selected tool as `selected_<engine-name>`. Selecting a new task replaces it; running it clears the selection. Status/cancellation use the Kloudy entry and the same engine request. Model-visible outputs never contain the bearer.
175
185
 
@@ -199,7 +209,7 @@ MCP transport compatibility is pinned to the official SDK 1.31.0 and its 2025-11
199
209
 
200
210
  `npm test` covers real HTTP/stdio round trips, selected-tool isolation, auth/origin rejection, crash handling, shared SDK behavior, explicit approval and safe IDE installation.
201
211
 
202
- The real-engine acceptance script uses a separately provisioned local Blackbox instance and synthetic public metadata, never a mock engine. It exercises CLI → MCP → SDK → autonomous completed Notes result, with no extra approval call. It intentionally requires an explicit test flag:
212
+ The real-engine acceptance script uses a separately provisioned local Blackbox instance and synthetic public metadata, never a mock engine. It exercises CLI → MCP → SDK → autonomous completed Notes result, with no extra approval call. It intentionally requires an explicit test flag:
203
213
 
204
214
  ```sh
205
215
  node scripts/prove.mjs --connection /private/test-connection.json --candidates /path/to/test-cards.json --approve-synthetic
@@ -223,9 +233,9 @@ A standard remote MCP host sees one `kloudy` tool. `introduce` gets the engine g
223
233
 
224
234
  `node scripts/prove-hosted.mjs` uses an in-memory, expiring test grant and a temporary user config to prove the real public MCP handshake, engine greeting, one selected tool, completed Notes receipt, idempotent replay and SDK readback. It does not claim a visual IDE session or the provider-model failure ladder was tested. Those remaining checks are recorded in Shared.
225
235
 
226
- ## Selected Toolboxes — 0.3.1
236
+ ## Selected Toolboxes — 0.3.1
227
237
 
228
- With a scoped HTTPK connection, each ask returns its engine selection_id. Collect 1–16 distinct IDs from that same session. Assembly is passive: no model, action or approval runs.
238
+ With a scoped HTTPK connection, each ask returns its engine selection_id. Collect 1–16 distinct IDs from that same session. Assembly is passive: no model, action or approval runs.
229
239
 
230
240
  ```sh
231
241
  kloudy ask "read note NOTE_ID" --connection /private/httpk.json
@@ -245,7 +255,7 @@ Open 0.3.4 adds `HttpkClient.confirm({selectionId, token, decision: 'approve', s
245
255
 
246
256
  The same public MCP gateway accepts `operation: 'confirm'`, `selection_id`, `decision: 'approve'`, `source`, and exactly one of `token` or `revision`. Missing binding/source fails closed. `scripts/prove-confirmation.mjs` verifies a real bounded Base read, wrong-token denial, zero execution before approval and receipt replay using an existing finite grant from memory. No provider key is distributed.
247
257
 
248
- ## IDE experience — 0.3.4
258
+ ## IDE experience — 0.3.4
249
259
 
250
260
  An already attached MCP host receives a welcome and current-project guidance, not another install question. Kloudy or /kloudy explicitly addresses the service; the IDE may use the same tools for relevant tasks without a prefix. The current task selects a small toolbox; a persistent account connection does not load its tools into every project.
251
261
 
@@ -264,7 +274,7 @@ const list = await convertPages(['https://example.com', 'https://example.com/mis
264
274
  console.log(list.cards, list.reports); // Each unreadable source is reported.
265
275
  ```
266
276
 
267
- MCP's single `kloudy` door also accepts `operation: "convert", url` or `operation: "yield_list", urls` (1–8). No account is needed for these public reads. The engine owns KLDY query matching and ranking. This client returns content and per-source conversion reports, never a ranked recommendation or permission to execute tools.
277
+ MCP's single `kloudy` door also accepts `operation: "convert", url` or `operation: "yield_list", urls` (1–8). No account is needed for these public reads. The engine owns KLDY query matching and ranking. This client returns content and per-source conversion reports, never a ranked recommendation or permission to execute tools.
268
278
 
269
279
  Cards validate against the unmodified SDF v0.2 root schema pinned to `sdfprotocol/sdf@e4619ad0b951ced8542692c6b624cb86794cb863` and the additional Kloudy webpage profile. This upstream machine-readable schema differs from its website examples: it uses `sdf:<sha256>`, `summary.brief`, `source.fetched_at`, and section `title`/`summary`. Schemas and upstream MIT notice ship in `schemas/` and at [kloudy.ai/schemas/sdf-webpage-v1.schema.json](https://kloudy.ai/schemas/sdf-webpage-v1.schema.json). `article.x-kloudy-webpage` is our explicit extractive subtype, not a claim that an arbitrary page is a news article. Empty entities mean no semantic entity extraction was performed.
270
280
 
@@ -374,7 +384,7 @@ Sources: https://docs.ollama.com/api/tags ; https://docs.ollama.com/api/openai-c
374
384
 
375
385
  ### Host interface and deterministic replies
376
386
 
377
- HTTPK initialization negotiates chat/text/speech presentation with BBE 0.3.50. It never changes scopes, the credential surface or the approval policy. Older engines remain usable without invented negotiation. `renderReply(state,{action})` from `kloudy/replies` renders bounded JSON templates for observed operation state. Unknown state stays unconfirmed; output is plain text. The browser uses the same templates in its one conversation. Kloudy’s subscription NVIDIA ladder is the text/planning upgrade; supported local Siri/Nano remains voice. Bot Boozle is a separate optional IDE.
387
+ HTTPK initialization negotiates chat/text/speech presentation with BBE 0.3.50. It never changes scopes, the credential surface or the approval policy. Older engines remain usable without invented negotiation. `renderReply(state,{action})` from `kloudy/replies` renders bounded JSON templates for observed operation state. Unknown state stays unconfirmed; output is plain text. The browser uses the same templates in its one conversation. Kloudy’s subscription NVIDIA ladder is the text/planning upgrade; supported local Siri/Nano remains voice. Bot Boozle is a separate optional IDE.
378
388
 
379
389
  ## Encrypted conversation client
380
390
 
package/bin/entry.mjs CHANGED
@@ -1,26 +1,32 @@
1
- #!/usr/bin/env node
2
- import {readFileSync} from 'node:fs';
3
- const version=JSON.parse(readFileSync(new URL('../package.json',import.meta.url),'utf8')).version;
4
- import {createInterface} from 'node:readline/promises';
5
- import {installEditors} from '../src/editor-install.mjs';
6
- import {askRemote,serveRemote} from '../src/remote.mjs';
7
- const args=process.argv.slice(2);
8
- try{
9
- if(!args.length||args[0]==='install'&&args.length===1){
10
- console.log('Kloudy · Add to your editor\nFree public discovery. Account actions require an authorized Kloudy key.');
11
- let rl;
12
- const confirm=async({client,path,authenticated})=>{if(!process.stdin.isTTY){console.log(`${client}: skipped — run in an interactive terminal to approve ${path}`);return false;}rl??=createInterface({input:process.stdin,output:process.stdout});return /^(y|yes)$/i.test((await rl.question(`Add Kloudy to ${client}? ${path}${authenticated?' (stores your key)':''} [y/N] `)).trim());};
13
- let results;try{results=await installEditors({confirm,key:process.env.KLOUDY_MCP_TOKEN,onResult:r=>console.log(`${r.client}: ${r.state}`)});}finally{rl?.close();}
14
- if(!results.length)console.log('No supported editor configuration found. Open https://kloudy.ai/install for setup help.');
15
- else console.log('Existing servers were preserved. Restart configured clients to connect.');
16
- if(results.some(r=>r.state==='unchanged_error'))process.exitCode=1;
17
- }else if(args[0]==='mcp'&&args.length===1){await serveRemote();}
18
- else if(args[0]==='--version'){console.log(version);}
19
- else if(['--help','help'].includes(args[0]))console.log('npx kloudy Add to detected editors (asks before each write)\nnpx kloudy <question> Ask the public Kloudy MCP endpoint\nnpx kloudy mcp Remote MCP over local stdio\nnpx kloudy recommend "<goal>" [--json] [--limit N] [--offline]\nnpx kloudy stack "<goal>" [--json] [--limit N] [--offline]\nnpx kloudy feedback --stack ID --bot ID --result worked|failed|partial [--reason TEXT] [--client NAME] [--flush]\nnpx kloudy agents\nnpx kloudy bots list|search|get|sync|works|broken|modalities [--offline]\nnpx kloudy legacy <args> Existing SDK/engine CLI commands\n\nFree discovery needs no key. For authorized account actions set KLOUDY_MCP_TOKEN privately; never put a key in command arguments. Node 20.19+ required.');
20
- else if(args[0]==='bots'){const {botsCommand}=await import('../src/bots.mjs');try{await botsCommand(args.slice(1));}catch(error){process.stderr.write((error?.message||'bots failed')+'\n');process.exitCode=1;}}
21
- else if(args[0]==='recommend'||args[0]==='stack'){const {recommendCommand}=await import('../src/recommend.mjs');try{await recommendCommand(args.slice(1));}catch(error){process.stderr.write((error?.message||'recommend failed')+'\n');process.exitCode=1;}}
22
- else if(args[0]==='feedback'){const {feedbackCommand}=await import('../src/feedback.mjs');try{await feedbackCommand(args.slice(1));}catch(error){process.stderr.write((error?.message||'feedback failed')+'\n');process.exitCode=1;}}
23
- else if(args[0]==='agents'||args[0]==='agent'){const {agentsCommand}=await import('../src/recommend.mjs');try{agentsCommand();}catch(error){process.stderr.write((error?.message||'agents failed')+'\n');process.exitCode=1;}}
24
- else if(args[0]==='legacy'){process.argv.splice(2,1);await import('./kloudy.mjs');}
25
- else{if(args[0]?.startsWith('--'))throw Error('Unknown option');const result=await askRemote(args.join(' '));for(const c of result.content||[])if(c.type==='text')process.stdout.write((process.env.KLOUDY_MCP_TOKEN?c.text.replaceAll(process.env.KLOUDY_MCP_TOKEN,'[redacted]'):c.text)+'\n');if(result.isError)process.exitCode=1;}
26
- }catch{process.stderr.write('Kloudy could not complete the request. Check your connection or authorized key. No action was retried. Run npx kloudy --help.\n');process.exitCode=1;}
1
+ #!/usr/bin/env node
2
+ import {readFileSync} from 'node:fs';
3
+ const version=JSON.parse(readFileSync(new URL('../package.json',import.meta.url),'utf8')).version;
4
+ import {createInterface} from 'node:readline/promises';
5
+ import {installEditors} from '../src/editor-install.mjs';
6
+ import {askRemote,serveRemote} from '../src/remote.mjs';
7
+ const args=process.argv.slice(2);
8
+ function remoteArgs(values){
9
+ const words=[...values],indexes=words.flatMap((value,index)=>value==='--connection'?[index]:[]);let options={};
10
+ if(indexes.length){const i=indexes[0],file=words[i+1];if(indexes.length!==1||typeof file!=='string'||!file.trim()||file.startsWith('--'))throw Error('Private connection path required');options={connection:file};words.splice(i,2);}
11
+ if(words.some(value=>value.startsWith('--')))throw Error('Unknown option');
12
+ return {words,options};
13
+ }
14
+ try{
15
+ if(!args.length||args[0]==='install'&&args.length===1){
16
+ console.log('Kloudy · Add to your editor\nFree public discovery. Account actions require an authorized Kloudy key.');
17
+ let rl;
18
+ const confirm=async({client,path,authenticated})=>{if(!process.stdin.isTTY){console.log(`${client}: skipped — run in an interactive terminal to approve ${path}`);return false;}rl??=createInterface({input:process.stdin,output:process.stdout});return /^(y|yes)$/i.test((await rl.question(`Add Kloudy to ${client}? ${path}${authenticated?' (stores your key)':''} [y/N] `)).trim());};
19
+ let results;try{results=await installEditors({confirm,key:process.env.KLOUDY_MCP_TOKEN,onResult:r=>console.log(`${r.client}: ${r.state}`)});}finally{rl?.close();}
20
+ if(!results.length)console.log('No supported editor configuration found. Open https://kloudy.ai/install for setup help.');
21
+ else console.log('Existing servers were preserved. Restart configured clients to connect.');
22
+ if(results.some(r=>r.state==='unchanged_error'))process.exitCode=1;
23
+ }else if(args[0]==='mcp'){const {words,options}=remoteArgs(args.slice(1));if(words.length)throw Error('Unexpected MCP arguments');await serveRemote(options);}
24
+ else if(args[0]==='--version'){console.log(version);}
25
+ else if(['--help','help'].includes(args[0]))console.log('npx kloudy Add to detected editors (asks before each write)\nnpx kloudy <question> Ask the public Kloudy MCP endpoint\nnpx kloudy mcp [--connection PRIVATE_FILE] Remote MCP over local stdio\nnpx kloudy <question> --connection PRIVATE_FILE Use an existing scoped connection\nnpx kloudy recommend "<goal>" [--json] [--limit N] [--offline]\nnpx kloudy stack "<goal>" [--json] [--limit N] [--offline]\nnpx kloudy feedback --stack ID --bot ID --result worked|failed|partial [--reason TEXT] [--client NAME] [--flush]\nnpx kloudy agents\nnpx kloudy bots list|search|get|sync|works|broken|modalities [--offline]\nnpx kloudy legacy <args> Existing SDK/engine CLI commands\n\nFree discovery needs no key. Remote MCP accepts KLOUDY_CONNECTION or --connection for a private scoped connection file, or the existing KLOUDY_MCP_TOKEN. Choose one credential source; no file is read implicitly. This does not issue grants. Never put a key in command arguments. Node 20.19+ required.');
26
+ else if(args[0]==='bots'){const {botsCommand}=await import('../src/bots.mjs');try{await botsCommand(args.slice(1));}catch(error){process.stderr.write((error?.message||'bots failed')+'\n');process.exitCode=1;}}
27
+ else if(args[0]==='recommend'||args[0]==='stack'){const {recommendCommand}=await import('../src/recommend.mjs');try{await recommendCommand(args.slice(1));}catch(error){process.stderr.write((error?.message||'recommend failed')+'\n');process.exitCode=1;}}
28
+ else if(args[0]==='feedback'){const {feedbackCommand}=await import('../src/feedback.mjs');try{await feedbackCommand(args.slice(1));}catch(error){process.stderr.write((error?.message||'feedback failed')+'\n');process.exitCode=1;}}
29
+ else if(args[0]==='agents'||args[0]==='agent'){const {agentsCommand}=await import('../src/recommend.mjs');try{agentsCommand();}catch(error){process.stderr.write((error?.message||'agents failed')+'\n');process.exitCode=1;}}
30
+ else if(args[0]==='legacy'){process.argv.splice(2,1);await import('./kloudy.mjs');}
31
+ else{if(args[0]==='install')throw Error('Remote connection options do not install editors');const {words,options}=remoteArgs(args);if(!words.length)throw Error('Question required');const result=await askRemote(words.join(' '),options);for(const c of result.content||[])if(c.type==='text')process.stdout.write((process.env.KLOUDY_MCP_TOKEN?c.text.replaceAll(process.env.KLOUDY_MCP_TOKEN,'[redacted]'):c.text)+'\n');if(result.isError)process.exitCode=1;}
32
+ }catch{process.stderr.write('Kloudy could not complete the request. Check your connection or authorized key. No action was retried. Run npx kloudy --help.\n');process.exitCode=1;}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kloudy",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Ask for Kloudy: CLI, MCP and SDK clients for the same scoped engine session.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,17 +1,17 @@
1
- // Public presentation adapter. URLs contain public template locators only, never
2
- // a connection credential, authorization token, goal text or identity claim.
3
- export const CHOOSER_URL='https://kloudy.ai/api/customer/visual-chooser/start';
4
- export const pendingChooserTool={name:'show_pending_chooser_link',description:'Show the pending Kloudy card and its HTTPS website fallback again. The website verifies account sign-in; the link is not authorization. This does not run a tool, model, installation or payment.',inputSchema:{type:'object',properties:{},additionalProperties:false},annotations:{readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false}};
5
- export function chooserResponse(result,{apps=false,goal}={}){
6
- if(!result||typeof result!=='object'||Array.isArray(result)||result.isError)return result;
7
- const data=result.structuredContent;if(!data||typeof data!=='object'||Array.isArray(data))return result;
8
- const option=typeof data.selectedOptionId==='string'&&/^[a-z0-9-]{1,80}$/.test(data.selectedOptionId)?data.selectedOptionId:null;
9
- const set=typeof data.choiceSetId==='string'&&/^visual-[a-f0-9]{1,64}$/.test(data.choiceSetId)?data.choiceSetId:null;
10
- const url=option&&set?CHOOSER_URL+'#choice='+encodeURIComponent(option)+'&set='+encodeURIComponent(set):CHOOSER_URL;
11
- const chooser={url,linkIsAuthorization:false,identityFromEmail:false,accountBindingVerified:false,returnMethod:'existing-authenticated-website-project-manifest',automaticIdeReturnVerified:false,...(set?{publicChoiceSetId:set}:{}),...(option?{publicOptionId:option}:{}),...(goal?{request:goal}:{})};
12
- const text='Website chooser: '+url+'\nOpen this HTTPS link if your host does not show the card. Complete account sign-in/2FA on the website before opening private project choices. Email is a lookup hint, not proof. The host controls card rendering, clickable links and browser launch. Account-bound automatic return is not established by this public template locator.';
13
- return {...result,content:[...(Array.isArray(result.content)?result.content:[]),{type:'text',text}],structuredContent:{...data,chooser},...(apps?{_meta:{...result._meta,ui:{...result._meta?.ui,resourceUri:'ui://widget/kloudy-visual-setup-v1.html'}}}:{})};
14
- }
15
- export function pendingChooserResponse(pending){
16
- return pending?structuredClone(pending):{content:[{type:'text',text:'No pending chooser is retained in this MCP process. Open '+CHOOSER_URL+' and sign in to recover your existing project. This link is not a bearer token or authorization.'}],structuredContent:{pending:false,chooser:{url:CHOOSER_URL,linkIsAuthorization:false,accountBindingVerified:false}}};
17
- }
1
+ // Public presentation adapter. URLs contain public template locators only, never
2
+ // a connection credential, authorization token, goal text or identity claim.
3
+ export const CHOOSER_URL='https://kloudy.ai/api/customer/visual-chooser/start';
4
+ export const pendingChooserTool={name:'show_pending_chooser_link',description:'Show the pending Kloudy card and its HTTPS website fallback again. The website verifies account sign-in; the link is not authorization. This does not run a tool, model, installation or payment.',inputSchema:{type:'object',properties:{},additionalProperties:false},annotations:{readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false}};
5
+ export function chooserResponse(result,{apps=false,goal}={}){
6
+ if(!result||typeof result!=='object'||Array.isArray(result)||result.isError)return result;
7
+ const data=result.structuredContent;if(!data||typeof data!=='object'||Array.isArray(data))return result;
8
+ const option=typeof data.selectedOptionId==='string'&&/^[a-z0-9-]{1,80}$/.test(data.selectedOptionId)?data.selectedOptionId:null;
9
+ const set=typeof data.choiceSetId==='string'&&/^visual-[a-f0-9]{1,64}$/.test(data.choiceSetId)?data.choiceSetId:null;
10
+ const url=option&&set?CHOOSER_URL+'#choice='+encodeURIComponent(option)+'&set='+encodeURIComponent(set):CHOOSER_URL;
11
+ const chooser={url,linkIsAuthorization:false,identityFromEmail:false,accountBindingVerified:false,returnMethod:'existing-authenticated-website-project-manifest',automaticIdeReturnVerified:false,...(set?{publicChoiceSetId:set}:{}),...(option?{publicOptionId:option}:{}),...(goal?{request:goal}:{})};
12
+ const text='Website chooser: '+url+'\nOpen this HTTPS link if your host does not show the card. Complete account sign-in/2FA on the website before opening private project choices. Email is a lookup hint, not proof. The host controls card rendering, clickable links and browser launch. Account-bound automatic return is not established by this public template locator.';
13
+ return {...result,content:[...(Array.isArray(result.content)?result.content:[]),{type:'text',text}],structuredContent:{...data,chooser},...(apps?{_meta:{...result._meta,ui:{...result._meta?.ui,resourceUri:'ui://widget/kloudy-visual-setup-v1.html'}}}:{})};
14
+ }
15
+ export function pendingChooserResponse(pending){
16
+ return pending?structuredClone(pending):{content:[{type:'text',text:'No pending chooser is retained in this MCP process. Open '+CHOOSER_URL+' and sign in to recover your existing project. This link is not a bearer token or authorization.'}],structuredContent:{pending:false,chooser:{url:CHOOSER_URL,linkIsAuthorization:false,accountBindingVerified:false}}};
17
+ }
@@ -5,10 +5,18 @@ const exact=(v,keys)=>{if(!plain(v)||Object.keys(v).length!==keys.length||keys.s
5
5
  const bounded=(v,max)=>typeof v==='string'&&v.trim().length>0&&[...v].length<=max;
6
6
  const identifier=(v,max=80)=>typeof v==='string'&&new RegExp(`^[a-zA-Z0-9_.:-]{1,${max}}$`).test(v);
7
7
  const integer=v=>Number.isSafeInteger(v)&&v>=0;
8
- export const conversationOperations=['read','append','sync'];
8
+ export const conversationOperations=['read','append','sync','history/read','history/configure'];
9
9
  export function conversationParams(operation,input){
10
10
  if(!conversationOperations.includes(operation))bad();
11
11
  const common=['project','conversation_id'];
12
+ if(operation.startsWith('history/')){
13
+ exact(input,[...common,...(operation==='history/read'?['after_sequence','limit']:['expected_revision','enabled'])]);
14
+ if(!identifier(input.project)||!identifier(input.conversation_id))bad();
15
+ if(operation==='history/read'){
16
+ if(!integer(input.after_sequence)||!Number.isSafeInteger(input.limit)||input.limit<1||input.limit>20)bad();
17
+ }else if(!integer(input.expected_revision)||input.expected_revision===Number.MAX_SAFE_INTEGER||typeof input.enabled!=='boolean')bad();
18
+ return structuredClone(input);
19
+ }
12
20
  const keys=operation==='read'?common:[...common,'idempotency_key','expected_revision',...(operation==='sync'?['reason']:['title','user','assistant','decisions','built_artifacts'])];
13
21
  exact(input,keys);
14
22
  if(!identifier(input.project)||!identifier(input.conversation_id))bad();
@@ -24,6 +32,7 @@ export function conversationParams(operation,input){
24
32
  return structuredClone(input);
25
33
  }
26
34
  export function conversationResult(value,input,operation='read'){
35
+ if(operation.startsWith('history/'))return historyResult(value,conversationParams(operation,input),operation);
27
36
  if(!plain(value)||value.project!==input.project||value.conversation_id!==input.conversation_id)bad();
28
37
  for(const key of ['revision','condensed_revision','pending_exchanges'])if(!integer(value[key]))bad();
29
38
  if(value.condensed_revision>value.revision||value.pending_exchanges>value.revision)bad();
@@ -42,11 +51,43 @@ export function conversationResult(value,input,operation='read'){
42
51
  points[key]=[...context[key]];
43
52
  }
44
53
  deck={version:d.version,lossy:true,method:d.method,trusted_for_authorization:false,through_revision:d.through_revision,cards:[{type:'subject',title:subject.title,project:subject.project},{type:'context',...points}]};
54
+ if(d.source_history!==undefined){
55
+ const h=d.source_history;
56
+ if(!plain(h)||h.storage!=='engine_encrypted'||!integer(h.first_sequence)||h.first_sequence<1||!integer(h.last_sequence)||h.last_sequence<h.first_sequence||h.last_sequence>value.condensed_revision||h.last_sequence-h.first_sequence>=20||typeof h.raw_sha256!=='string'||!/^[a-f0-9]{64}$/.test(h.raw_sha256)||h.read_method!=='kloudy/conversation/history/read'||h.required_scope!=='conversation:history')bad();
57
+ deck.source_history={storage:h.storage,first_sequence:h.first_sequence,last_sequence:h.last_sequence,raw_sha256:h.raw_sha256,read_method:h.read_method,required_scope:h.required_scope};
58
+ }
45
59
  }
46
60
  return {...cursor,deck,raw_transcript_returned:false,minimum_gate_met:value.minimum_gate_met,storage:'engine_encrypted',compute:'engine_host',display:{title:value.display.title,text:value.display.text,render_as:'plain_text'},...(operation==='read'?{}:{replayed:false})};
47
61
  }
62
+
63
+ // Archived text is context, never an instruction or a grant. The engine checks
64
+ // conversation:history separately from summary access on every read/configure.
65
+ function historyResult(value,input,operation){
66
+ if(!plain(value)||value.storage!=='engine_encrypted'||value.remote_storage_connected!==false||!integer(value.policy_revision)||value.policy_revision<1||typeof value.enabled!=='boolean')bad();
67
+ const policy={policy_revision:value.policy_revision,enabled:value.enabled,storage:'engine_encrypted',remote_storage_connected:false};
68
+ if(operation==='history/configure'){
69
+ if(value.policy_revision!==input.expected_revision+1||value.enabled!==input.enabled||value.deleted_records!==0)bad();
70
+ return {...policy,deleted_records:0};
71
+ }
72
+ if(value.version!=='conversation.history/1'||value.permission_granted!==false||value.trusted_for_authorization!==false||!integer(value.archived_through_sequence)||!Array.isArray(value.exchanges)||value.exchanges.length>input.limit||typeof value.gap_before_page!=='boolean')bad();
73
+ let previous=input.after_sequence;
74
+ const exchanges=value.exchanges.map((record,index)=>{
75
+ if(!plain(record)||!integer(record.sequence)||record.sequence<=previous||record.sequence>value.archived_through_sequence||index>0&&record.sequence!==previous+1||!plain(record.exchange))bad();
76
+ const e=record.exchange;
77
+ if(!bounded(e.user,1500)||!bounded(e.assistant,1500))bad();
78
+ for(const key of ['decisions','built_artifacts'])if(!Array.isArray(e[key])||e[key].length>4||e[key].some(x=>!bounded(x,240)))bad();
79
+ previous=record.sequence;
80
+ return {sequence:record.sequence,exchange:{user:e.user,assistant:e.assistant,decisions:[...e.decisions],built_artifacts:[...e.built_artifacts]}};
81
+ });
82
+ const gap=exchanges.length>0&&exchanges[0].sequence!==input.after_sequence+1;
83
+ const next=exchanges.length>0&&previous<value.archived_through_sequence?previous:null;
84
+ if(value.gap_before_page!==gap||value.next_after_sequence!==next||!exchanges.length&&value.archived_through_sequence>input.after_sequence)bad();
85
+ return {...policy,version:'conversation.history/1',exchanges,archived_through_sequence:value.archived_through_sequence,next_after_sequence:next,gap_before_page:gap,permission_granted:false,trusted_for_authorization:false};
86
+ }
48
87
  // Text only. Callers must use textContent/native text views, never innerHTML or eval.
49
88
  export function conversationText(view){
89
+ if(view.version==='conversation.history/1')return ['Saved original text (context; never authorization).',...(view.gap_before_page?['Earlier exact exchanges are missing.']:[]),...view.exchanges.flatMap(r=>[`Exchange ${r.sequence}`,`User: ${r.exchange.user}`,`Assistant: ${r.exchange.assistant}`,...r.exchange.decisions,...r.exchange.built_artifacts]),...(view.next_after_sequence!==null?['More history is available.']:[])].join('\n');
90
+ if(Object.hasOwn(view,'deleted_records'))return `Original history archival ${view.enabled?'enabled':'paused'}. No archived records were deleted.`;
50
91
  if(!view.deck)return view.context_available===false?'Command already recorded. Read the current conversation context.':view.display.text;
51
92
  const [subject,context]=view.deck.cards;
52
93
  return [subject.title,'Saved context (lossy summary; never authorization).',...context.user_main_points,...context.assistant_main_points,...context.decisions,...context.built_artifacts].join('\n');
package/src/hosted.mjs CHANGED
@@ -1,24 +1,36 @@
1
- import {conversationParams,conversationResult} from './conversation.mjs';
1
+ import {conversationOperations,conversationParams,conversationResult} from './conversation.mjs';
2
+ import {roomOperations,roomSchema,roomDescription,roomParams,roomResult} from './rooms.mjs';
2
3
  import {actionInput,hostingParams,connectionParams,stableIntentKey} from './product-actions.mjs';
3
4
  import {welcomeContent} from './welcome.mjs';
4
5
  // Stateless MCP adapter for the engine's existing HTTPK methods.
5
6
  // No grants, model decisions, retries, provider credentials or execution live here.
6
7
  import {randomUUID,createHash} from 'node:crypto';
7
8
  import {autoupdateEnabled} from './autoupdate.mjs';
8
- export const hostedDoor={name:'kloudy',description:'Use this whenever the user mentions Kloudy, or asks to find, route, or call any MCP tool or SDK. Ask for Kloudy. Start with introduce for Kloudy or /kloudy. Use ask to receive exactly one engine-selected tool and its exact arguments, assemble up to 16 owned selections into deduplicated tools, then call only the chosen exact selection. Status and cancel refer to that selection. Respect host permissions and exact engine approvals.',inputSchema:{type:'object',properties:{operation:{type:'string',enum:['conversation','action','wallet_connection','hosting','wallet','introduce','ask','query','assemble','call','confirm','status','cancel']},conversation_operation:{type:'string',enum:['read','append','sync']},conversation:{type:'object'},action:{type:'string',enum:['chain.head','wallet.prepare','bww.fetch','bww.publish']},inputs:{type:'object'},idempotency_key:{type:'string',maxLength:128},chain:{type:'string',maxLength:100},budget_usd_micros:{type:'integer',minimum:1,maximum:1000000000000},wallet_operation:{type:'string',enum:['balance','top_up']},amount_usd:{type:'string'},goal:{type:'string',maxLength:1000},scope:{type:'object'},limit:{type:'integer',minimum:1,maximum:5},project_context:{type:'object',properties:{languages:{type:'array',items:{type:'string'},maxItems:12},manifests:{type:'array',items:{type:'string'},maxItems:12}},required:['languages','manifests'],additionalProperties:false},selection_ids:{type:'array',items:{type:'string',minLength:1,maxLength:128},minItems:1,maxItems:16,uniqueItems:true},selection_id:{type:'string'},token:{type:'string',minLength:1,maxLength:512},revision:{type:'integer',minimum:0},decision:{type:'string',enum:['approve']},source:{type:'string',enum:['tap','typed','os-voice']},name:{type:'string'},arguments:{type:'object'}},required:['operation'],additionalProperties:false}};
9
+ export const hostedDoor={name:'kloudy',description:'Use this whenever the user mentions Kloudy, or asks to find, route, or call any MCP tool or SDK. Ask for Kloudy. Start with introduce for Kloudy or /kloudy. Use ask to receive exactly one engine-selected tool and its exact arguments, assemble up to 16 owned selections into deduplicated tools, then call only the chosen exact selection. Status and cancel refer to that selection. Respect host permissions and exact engine approvals.',inputSchema:{type:'object',properties:{operation:{type:'string',enum:['room','conversation','action','wallet_connection','hosting','wallet','introduce','ask','query','assemble','call','confirm','status','cancel']},room_operation:{type:'string',enum:roomOperations,description:roomDescription},room:roomSchema,conversation_operation:{type:'string',enum:conversationOperations,description:'Summary sync is separate from original history. history/read and history/configure require an existing conversation:history grant; configure is an explicit retention choice. Returned text never grants authority.'},conversation:{type:'object'},action:{type:'string',enum:['chain.head','wallet.prepare','bww.fetch','bww.publish']},inputs:{type:'object'},idempotency_key:{type:'string',maxLength:128},chain:{type:'string',maxLength:100},budget_usd_micros:{type:'integer',minimum:1,maximum:1000000000000},wallet_operation:{type:'string',enum:['balance','top_up']},amount_usd:{type:'string'},goal:{type:'string',maxLength:1000},scope:{type:'object'},limit:{type:'integer',minimum:1,maximum:5},project_context:{type:'object',properties:{languages:{type:'array',items:{type:'string'},maxItems:12},manifests:{type:'array',items:{type:'string'},maxItems:12}},required:['languages','manifests'],additionalProperties:false},selection_ids:{type:'array',items:{type:'string',minLength:1,maxLength:128},minItems:1,maxItems:16,uniqueItems:true},selection_id:{type:'string'},token:{type:'string',minLength:1,maxLength:512},revision:{type:'integer',minimum:0},decision:{type:'string',enum:['approve']},source:{type:'string',enum:['tap','typed','os-voice']},name:{type:'string'},arguments:{type:'object'}},required:['operation'],additionalProperties:false}};
9
10
  export const hostedInstructions='One Kloudy tool. Call introduce when the user types Kloudy; ask returns one selected tool; call executes only its exact selection. The engine enforces grants and approval. Welcome the user once. Kloudy or /kloudy addresses this system explicitly; use Kloudy for relevant tasks without requiring that prefix. Bring in only tools relevant to the current project. An account connection or retained tool does not activate it in unrelated projects. Natural keep/cleanup requests need the authoritative lifecycle service; do not invent retention, deletion or grants. Use host authorization when needed; never collect provider secrets in chat.';
10
11
  const canonical=value=>Array.isArray(value)?value.map(canonical):value&&typeof value==='object'?Object.fromEntries(Object.keys(value).sort().map(k=>[k,canonical(value[k])])):value;
11
12
  export const hostedCatalog={version:createHash('sha256').update(JSON.stringify(canonical({tools:[hostedDoor],instructions:hostedInstructions}))).digest('hex').slice(0,12),issued:new Date().toISOString(),minClient:null,deprecations:[]};
12
13
  const toolResult=result=>({content:[{type:'text',text:JSON.stringify(result)}],structuredContent:result});
14
+ function hostedCapabilities(capabilities,enabled){
15
+ const result={tools:{listChanged:enabled?(capabilities?.tools?.listChanged??true):false}};
16
+ // Events are an engine capability, not a catalog-rollout feature. Preserve only
17
+ // an advertised object; the adapter neither creates subscriptions nor grants access.
18
+ if(capabilities?.events&&typeof capabilities.events==='object'&&!Array.isArray(capabilities.events))result.events=capabilities.events;
19
+ const readiness=capabilities?.experimental?.httpk?.room_readiness;
20
+ if(readiness&&typeof readiness==='object'&&!Array.isArray(readiness)&&readiness.version===1&&readiness.method==='kloudy/room/readiness')
21
+ result.experimental={httpk:{room_readiness:{version:1,method:'kloudy/room/readiness'}}};
22
+ return result;
23
+ }
13
24
  export function adaptMcp(message,{autoupdate=process.env.KLOUDY_MCP_AUTOUPDATE}={}){
14
25
  const enabled=autoupdateEnabled(autoupdate);
15
26
  let upstream=message,project=x=>x;
16
- if(message.method==='initialize')project=x=>({...x,instructions:hostedInstructions,capabilities:{tools:{listChanged:enabled?(x.capabilities?.tools?.listChanged??true):false}},...(enabled?{_meta:{...x._meta,'ai.kloudy/catalog':hostedCatalog}}:{})});
27
+ if(message.method==='initialize')project=x=>({...x,instructions:hostedInstructions,capabilities:hostedCapabilities(x.capabilities,enabled),...(enabled?{_meta:{...x._meta,'ai.kloudy/catalog':hostedCatalog}}:{})});
17
28
  if(message.method==='tools/list'&&!message.params?._meta?.selection_ids)project=()=>({tools:[hostedDoor],...(enabled?{_meta:{'ai.kloudy/catalog':hostedCatalog}}:{})});
18
29
  if(message.method==='tools/call'&&message.params?.name==='kloudy'){
19
30
  const a=message.params.arguments||{},operation=a.operation||'introduce';
20
31
  let method,params;
21
- if(operation==='conversation'){method='kloudy/conversation/'+a.conversation_operation;params=conversationParams(a.conversation_operation,a.conversation);}
32
+ if(operation==='room'){method='kloudy/room/'+a.room_operation;params=roomParams(a.room_operation,a.room);}
33
+ else if(operation==='conversation'){method='kloudy/conversation/'+a.conversation_operation;params=conversationParams(a.conversation_operation,a.conversation);}
22
34
  else if(operation==='action'){method='kloudy/intent';params={idempotency_key:stableIntentKey(a.idempotency_key||randomUUID()),input:actionInput(a.action,a.inputs)};}
23
35
  else if(operation==='wallet_connection'){method='kloudy/wallet/connection';params=connectionParams(a.chain);}
24
36
  else if(operation==='hosting'){method='kloudy/hosting';params=hostingParams(a.budget_usd_micros);}
@@ -29,7 +41,7 @@ export function adaptMcp(message,{autoupdate=process.env.KLOUDY_MCP_AUTOUPDATE}=
29
41
  else if(operation==='confirm'){method='kloudy/confirm';params=confirmationParams(a);}
30
42
  else if(operation==='status'||operation==='cancel'){method=operation==='status'?'kloudy/receipt':'kloudy/cancel';params={selection_id:a.selection_id};}
31
43
  else throw Error('unsupported_operation');
32
- upstream={...message,method,params};project=operation==='conversation'?x=>toolResult(conversationResult(x,params,a.conversation_operation)):operation==='call'?x=>x:operation==='introduce'?x=>({...toolResult(x),content:welcomeContent(x)}):toolResult;
44
+ upstream={...message,method,params};project=operation==='room'?x=>toolResult(roomResult(a.room_operation,x,params)):operation==='conversation'?x=>toolResult(conversationResult(x,params,a.conversation_operation)):operation==='call'?x=>x:operation==='introduce'?x=>({...toolResult(x),content:welcomeContent(x)}):toolResult;
33
45
  }
34
46
  return {upstream,project};
35
47
  }
package/src/httpk.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import {conversationParams,conversationResult} from './conversation.mjs';
2
+ import {roomParams,roomResult,roomEventParams} from './rooms.mjs';
2
3
  import {hostCapabilities,readHostInterface} from './host-interface.mjs';
3
4
  import {confirmationParams} from './hosted.mjs';
4
5
  import {queryInput,queryResult} from './query.mjs';
@@ -13,12 +14,16 @@ export class HttpkClient {
13
14
  if(typeof token!=='string'||!token||/[\r\n]/.test(token))fail('connection_unavailable','A scoped user connection is required.');
14
15
  this.capabilities=hostCapabilities(host);this.hostInterface=readHostInterface(state.hostInterface??undefined);this.endpoint=u.href;this.token=token;this.state=state;this.save=save;this.mode=mode;this.fetcher=fetcher;
15
16
  }
16
- async rpc(method,params={}){
17
- const body=JSON.stringify({jsonrpc:'2.0',id:randomUUID(),method,params});if(Buffer.byteLength(body)>8192)fail('too_large','HTTPK request exceeds the engine limit.');
17
+ async rpc(method,params={},strict=false){
18
+ const id=randomUUID(),body=JSON.stringify({jsonrpc:'2.0',id,method,params});if(Buffer.byteLength(body)>8192)fail('too_large','HTTPK request exceeds the engine limit.');
18
19
  let response;try{response=await this.fetcher(this.endpoint,{method:'POST',redirect:'error',signal:AbortSignal.timeout(20000),headers:{authorization:'Bearer '+this.token,'content-type':'application/json',accept:'application/json, text/event-stream','mcp-protocol-version':'2025-06-18',...(this.state.sessionId?{'mcp-session-id':this.state.sessionId}:{})},body});}catch{fail('engine_unavailable','No retry was made. Check the same selection receipt before repeating an action.');}
19
- let size=0;const chunks=[];for await(const chunk of response.body){size+=chunk.length;if(size>524288)fail('invalid_response','Engine response too large.');chunks.push(chunk);}
20
- let json;try{json=JSON.parse(Buffer.concat(chunks).toString());}catch{fail('invalid_response','The endpoint did not return MCP JSON.');}
20
+ if(strict&&response.headers.get('content-type')?.split(';')[0].trim().toLowerCase()!=='application/json'){await response.body?.cancel();fail('invalid_response','Expected the scoped engine JSON response. No retry was made.');}
21
+ if(strict&&!response.body)fail('invalid_response','The engine response was empty. No retry was made.');
22
+ let size=0;const chunks=[];for await(const chunk of response.body){size+=chunk.length;if(size>(strict?262144:524288))fail('invalid_response','Engine response too large.');chunks.push(chunk);}
23
+ let json;try{const bytes=Buffer.concat(chunks);json=JSON.parse(strict?new TextDecoder('utf-8',{fatal:true}).decode(bytes):bytes.toString());}catch{fail('invalid_response','The endpoint did not return MCP JSON.');}
24
+ if(strict&&(json?.jsonrpc!=='2.0'||json.id!==id))fail('invalid_response','The engine response did not match this request. No retry was made.');
21
25
  if(!response.ok||json.error)fail('httpk_request_failed','The engine rejected this request. No retry or approval was fabricated.');
26
+ if(strict&&(!json.result||typeof json.result!=='object'||Array.isArray(json.result)))fail('invalid_response','Expected an engine room result. No retry was made.');
22
27
  return json.result;
23
28
  }
24
29
  async session(){if(this.state.sessionId&&this.mode!=='auto'&&this.state.interactionMode!==this.mode)fail('mode_not_supported','Saved session mode does not match this client.');if(!this.state.sessionId){const opened=await this.rpc('initialize',{protocolVersion:'2025-06-18',clientInfo:{name:'kloudy-open',version:'0.3.15'},capabilities:this.capabilities});const negotiated=readHostInterface(opened.host_interface);if(typeof opened.session_id!=='string'||!['autonomous','confirm'].includes(opened.interaction_mode)||(this.mode!=='auto'&&opened.interaction_mode!==this.mode))fail('mode_not_supported','Credential surface does not match this client mode.');this.hostInterface=negotiated;this.state.hostInterface=negotiated;this.state.sessionId=opened.session_id;this.state.interactionMode=opened.interaction_mode;await this.save(this.state);}return this.state.sessionId;}
@@ -32,6 +37,8 @@ export class HttpkClient {
32
37
  async introduce(project_context){await this.session();const result=await this.rpc('kloudy/intent',{idempotency_key:randomUUID(),input:{type:'text',text:'Kloudy',project_context}});if(result.state!=='greeting'||result.greeting?.version!=='bbe.attach.v1')fail('invalid_response','Expected the engine attach greeting.');return result.greeting;}
33
38
  async query(need,options={}){const input=queryInput(need,options);await this.session();return queryResult(await this.rpc('kloudy/query',input),input.limit);}
34
39
  async conversation(operation,input){const params=conversationParams(operation,input);await this.session();return conversationResult(await this.rpc('kloudy/conversation/'+operation,params),params,operation);}
40
+ async room(operation,input){const params=roomParams(operation,input);await this.session();return roomResult(operation,await this.rpc('kloudy/room/'+operation,params,true),params);}
41
+ async events(operation,input={}){const params=roomEventParams(operation,input);await this.session();return this.rpc('events/'+operation,params,true);}
35
42
  async wallet({operation='balance',amount_usd}={}){
36
43
  if(!['balance','top_up'].includes(operation)||operation==='balance'&&amount_usd!==undefined||operation==='top_up'&&(typeof amount_usd!=='string'||!/^\d{1,6}(?:\.\d{1,2})?$/.test(amount_usd)||Number(amount_usd)<=0))fail('invalid_input','Use wallet balance or top_up with a positive decimal amount_usd string.');
37
44
  await this.session();const result=await this.rpc('kloudy/wallet',{operation,...(operation==='top_up'?{amount_usd}:{})});
package/src/mcp.mjs CHANGED
@@ -1,74 +1,76 @@
1
- import {conversationParams,conversationResult} from './conversation.mjs';
2
- import {instrumentToolHandler} from './activity.mjs';
3
- import {configuredModels} from './models.mjs';
4
- import {actionInput,hostingParams,connectionParams,stableIntentKey} from './product-actions.mjs';
5
- import {welcomeContent} from './welcome.mjs';
6
- import {Approvals,approvalTool,approvalResource} from './approval.mjs';
7
- import {approvalHTML} from './approval-ui.mjs';
8
- import {getUiCapability,RESOURCE_MIME_TYPE} from '@modelcontextprotocol/ext-apps/server';
9
- import {convertPage,convertPages} from './public.mjs';
10
- import {localStrings} from './strings.mjs';
11
- import {Server} from '@modelcontextprotocol/sdk/server/index.js';
12
- import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js';
13
- import {ListToolsRequestSchema,CallToolRequestSchema,ListPromptsRequestSchema,GetPromptRequestSchema,ListResourcesRequestSchema,ReadResourceRequestSchema} from '@modelcontextprotocol/sdk/types.js';
14
- import {fileURLToPath} from 'node:url';
15
- import {introduce,isAttachIntent,ideGuidance} from './introduce.mjs';
16
- import {withConnection} from './connection.mjs';
17
- const gateway={name:'kloudy',description:'Use this whenever the user mentions Kloudy, or asks to find, route, or call any MCP tool or SDK. For Kloudy or /kloudy alone, use introduce to welcome this project; an attached host does not need another install question. Select one tool through the same engine. Autonomous by default; the IDE permission system controls tool calls and the engine enforces authorization.',inputSchema:{type:'object',properties:{operation:{type:'string',enum:['conversation','host_interface','models_list','action','wallet_connection','hosting','wallet','introduce','decline','convert','yield_list','query','ask','assemble','call','status','cancel','strings_list','strings_save','strings_call','strings_use','strings_keep','strings_remove','strings_prune']},conversation_operation:{type:'string',enum:['read','append','sync']},conversation:{type:'object'},action:{type:'string',enum:['chain.head','wallet.prepare','bww.fetch','bww.publish']},inputs:{type:'object'},idempotency_key:{type:'string',maxLength:128},chain:{type:'string',maxLength:100},budget_usd_micros:{type:'integer',minimum:1,maximum:1000000000000},wallet_operation:{type:'string',enum:['balance','top_up']},amount_usd:{type:'string'},goal:{type:'string'},scope:{type:'object'},limit:{type:'integer',minimum:1,maximum:5},url:{type:'string'},urls:{type:'array',minItems:1,maxItems:8,items:{type:'string'}},resolution:{type:'string',enum:['compact','standard']},string_name:{type:'string'},choices:{type:'array',maxItems:16,items:{type:'object',properties:{source:{type:'string'},kind:{type:'string',enum:['mcp','sdk','cli']},goal:{type:'string'}},required:['source','kind','goal'],additionalProperties:false}},selection_ids:{type:'array',items:{type:'string'},minItems:1,maxItems:16,uniqueItems:true},selection_id:{type:'string'},name:{type:'string'},arguments:{type:'object'},candidates:{type:'array',maxItems:16,items:{type:'object'}},lane:{type:'string',enum:['non_medical','medical']}},additionalProperties:false}};
18
- export function createMcpServer({useClient,greet=introduce,strings=localStrings,models=configuredModels,onActivity}){
19
- const server=new Server({name:'kloudy',version:'0.3.15'},{capabilities:{tools:{listChanged:true},prompts:{},resources:{}},instructions:'When the user says Kloudy or /kloudy, use operation introduce. Return the current project greeting. Do not repeatedly ask to install an already attached tool. Respect a no answer and the host permission ceiling. '+Object.values(ideGuidance).join(' ')});
20
- const approvals=new Approvals(),hasUI=()=>getUiCapability(server.getClientCapabilities())?.mimeTypes?.includes(RESOURCE_MIME_TYPE);
21
- server.setRequestHandler(ListResourcesRequestSchema,async()=>({resources:hasUI()?[{uri:approvalResource,name:'Kloudy action approval',mimeType:RESOURCE_MIME_TYPE}]:[]}));
22
- server.setRequestHandler(ReadResourceRequestSchema,async request=>{if(!hasUI()||request.params.uri!==approvalResource)throw Error('Unsupported UI resource');return {contents:[{uri:approvalResource,mimeType:RESOURCE_MIME_TYPE,text:approvalHTML,_meta:{ui:{csp:{connectDomains:[],resourceDomains:[]}}}}]};});
23
- async function projectRoot(){try{const roots=await server.listRoots();if(roots.roots?.[0]?.uri?.startsWith('file:'))return fileURLToPath(roots.roots[0].uri);}catch{}return process.env.CLAUDE_PROJECT_DIR||process.cwd();}
24
- async function greeting(decision){let project=process.env.CLAUDE_PROJECT_DIR||process.cwd();try{const roots=await server.listRoots();if(roots.roots?.[0]?.uri?.startsWith('file:'))project=fileURLToPath(roots.roots[0].uri);}catch{}return greet({project,decision,hostAttached:true});}
25
- server.setRequestHandler(ListPromptsRequestSchema,async()=>({prompts:[{name:'kloudy',description:'Welcome the current project to Kloudy and explain how to use it.'}]}));
26
- server.setRequestHandler(GetPromptRequestSchema,async request=>{if(request.params.name!=='kloudy')throw Error('Unknown prompt');const result=await greeting();return {description:'Ask for Kloudy',messages:[{role:'assistant',content:welcomeContent(result)[0]}]};});
27
- server.setRequestHandler(ListToolsRequestSchema,async()=>{
28
- let selected;try{selected=await useClient(client=>client.state.selection?.tool);}catch{}
29
- const meta=hasUI()?{_meta:{ui:{resourceUri:approvalResource}}}:{};return {tools:[{...gateway,...meta},...(selected?[{...selected,name:'selected_'+selected.name,...meta}]:[]),...(hasUI()?[approvalTool]:[])]};
30
- });
31
- server.setRequestHandler(CallToolRequestSchema,instrumentToolHandler(async request=>{
32
- try{
33
- const supplied=request.params.arguments||{};
34
- if(request.params.name==='kloudy'&&supplied.operation==='models_list'){if(Object.keys(supplied).length!==1)throw Error('Model discovery uses host configuration only');const result=await models().list();return {content:[{type:'text',text:JSON.stringify(result)}],structuredContent:result};}
35
-
36
- if(request.params.name==='kloudy'&&['convert','yield_list'].includes(supplied.operation)){const result=await (supplied.operation==='convert'?convertPage(supplied.url,{resolution:supplied.resolution}):convertPages(supplied.urls,{resolution:supplied.resolution}));return {content:[{type:'text',text:JSON.stringify(result)}],structuredContent:result};}
37
- if(request.params.name==='kloudy_approval'){if(!hasUI())throw Error('This host has no inline approval UI');const result=await approvals.decide(supplied,useClient);return {content:[{type:'text',text:JSON.stringify(result)}],structuredContent:result};}
38
- if(request.params.name==='kloudy'&&supplied.operation?.startsWith('strings_')){const store=strings({project:await projectRoot(),mode:'auto'}),op=supplied.operation.slice(8);let result;
39
- if(op==='save')result=await store.save(supplied.string_name,supplied.choices);
40
- else if(op==='call'){result=await store.call(supplied.string_name,supplied.name,supplied.arguments,{selectionId:supplied.selection_id});if(result?.state==='awaiting_approval'){const saved=(await store.list()).find(s=>s.name===supplied.string_name),route=saved?.activeRoutes?.find(r=>r.name===supplied.name&&(!supplied.selection_id||r.selection_id===supplied.selection_id));if(!route)throw Error('Selected route unavailable');return approvals.present(result,route.selection_id,work=>store.connect(route.source,route.kind,work));}}
41
- else if(op==='list')result=await store.list();
42
- else if(op==='use'){result=await store.use(supplied.string_name);}
43
- else if(['keep','remove'].includes(op))result=await store[op](supplied.string_name);
44
- else if(op==='prune')result=await store.prune();else throw Error('Unsupported strings operation');
45
- return {content:[{type:'text',text:JSON.stringify(result)}],structuredContent:Array.isArray(result)?{strings:result}:result};}
46
-
47
- if(request.params.name==='kloudy'&&(!supplied.operation||supplied.operation==='introduce'||supplied.operation==='decline'||supplied.operation==='ask'&&isAttachIntent(supplied.goal))){const result=await greeting(supplied.operation==='decline'?'no':undefined);return {content:welcomeContent(result),structuredContent:result};}
48
- const result=await useClient(async client=>{
49
- const input=request.params.arguments||{};
50
- if(request.params.name.startsWith('selected_'))return client.call(request.params.name.slice(9),input);
51
- if(request.params.name!=='kloudy')throw new Error('Unknown tool');
52
- if(input.operation==='conversation'){if(!client.conversation)throw Error('Conversation sync requires a scoped HTTPK connection');return client.conversation(input.conversation_operation,input.conversation);}
53
- if(input.operation==='host_interface')return client.negotiateHost();
54
- if(input.operation==='action')return client.action(input.action,input.inputs,{idempotencyKey:input.idempotency_key});
55
- if(input.operation==='wallet_connection')return client.walletConnection(input.chain);
56
- if(input.operation==='hosting')return client.hosting({budgetUsdMicros:input.budget_usd_micros});
57
- if(input.operation==='wallet'){if(!client.wallet)throw Error('Wallet previews require HTTPK');return client.wallet({operation:input.wallet_operation||'balance',amount_usd:input.amount_usd});}
58
- if(input.operation==='query'){if(!client.query)throw Error('Query needs an HTTPK connection');return client.query(input.goal,{scope:input.scope,limit:input.limit});}
59
- if(input.operation==='ask')return client.ask(input.goal,{candidates:input.candidates||[],lane:input.lane||'non_medical'});
60
- if(input.operation==='assemble'){if(!client.assemble)throw Error('Assembly requires an HTTPK connection');return client.assemble(input.selection_ids);}
61
- if(input.operation==='call'){if(!client.assemble||!input.selection_id)throw Error('Exact HTTPK selection required');return client.call(input.name,input.arguments,{selectionId:input.selection_id});}
62
- if(input.operation==='status')return client.status();
63
- if(input.operation==='cancel')return client.decide('cancel');
64
- throw new Error('Unsupported operation');
65
- });
66
- // Prompt the host to fetch the newly selected singular tool, not a server dump.
67
- server.sendToolListChanged().catch(()=>{});
68
- if(result?.state==='awaiting_approval')return approvals.present(result,result.selection_id);
69
- return {content:[{type:'text',text:JSON.stringify(result)}],structuredContent:result};
70
- }catch(error){return {isError:true,content:[{type:'text',text:JSON.stringify({error:error.code||'request_failed',message:error.code?error.message:'The tool request could not be completed.'})}]};}
71
- },onActivity));
72
- return server;
73
- }
74
- export async function serveMcp(connection){const server=createMcpServer({useClient:work=>withConnection(connection,work,{mode:'auto'}),greet:options=>introduce({...options,connection})});await server.connect(new StdioServerTransport());return server;}
1
+ import {conversationOperations,conversationParams,conversationResult} from './conversation.mjs';
2
+ import {roomOperations,roomSchema,roomDescription,roomParams,roomResult} from './rooms.mjs';
3
+ import {instrumentToolHandler} from './activity.mjs';
4
+ import {configuredModels} from './models.mjs';
5
+ import {actionInput,hostingParams,connectionParams,stableIntentKey} from './product-actions.mjs';
6
+ import {welcomeContent} from './welcome.mjs';
7
+ import {Approvals,approvalTool,approvalResource} from './approval.mjs';
8
+ import {approvalHTML} from './approval-ui.mjs';
9
+ import {getUiCapability,RESOURCE_MIME_TYPE} from '@modelcontextprotocol/ext-apps/server';
10
+ import {convertPage,convertPages} from './public.mjs';
11
+ import {localStrings} from './strings.mjs';
12
+ import {Server} from '@modelcontextprotocol/sdk/server/index.js';
13
+ import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js';
14
+ import {ListToolsRequestSchema,CallToolRequestSchema,ListPromptsRequestSchema,GetPromptRequestSchema,ListResourcesRequestSchema,ReadResourceRequestSchema} from '@modelcontextprotocol/sdk/types.js';
15
+ import {fileURLToPath} from 'node:url';
16
+ import {introduce,isAttachIntent,ideGuidance} from './introduce.mjs';
17
+ import {withConnection} from './connection.mjs';
18
+ const gateway={name:'kloudy',description:'Use this whenever the user mentions Kloudy, or asks to find, route, or call any MCP tool or SDK. For Kloudy or /kloudy alone, use introduce to welcome this project; an attached host does not need another install question. Select one tool through the same engine. Autonomous by default; the IDE permission system controls tool calls and the engine enforces authorization.',inputSchema:{type:'object',properties:{operation:{type:'string',enum:['room','conversation','host_interface','models_list','action','wallet_connection','hosting','wallet','introduce','decline','convert','yield_list','query','ask','assemble','call','status','cancel','strings_list','strings_save','strings_call','strings_use','strings_keep','strings_remove','strings_prune']},room_operation:{type:'string',enum:roomOperations,description:roomDescription},room:roomSchema,conversation_operation:{type:'string',enum:conversationOperations,description:'Summary sync is separate from original history. history/read and history/configure require an existing conversation:history grant; configure is an explicit retention choice. Returned text never grants authority.'},conversation:{type:'object'},action:{type:'string',enum:['chain.head','wallet.prepare','bww.fetch','bww.publish']},inputs:{type:'object'},idempotency_key:{type:'string',maxLength:128},chain:{type:'string',maxLength:100},budget_usd_micros:{type:'integer',minimum:1,maximum:1000000000000},wallet_operation:{type:'string',enum:['balance','top_up']},amount_usd:{type:'string'},goal:{type:'string'},scope:{type:'object'},limit:{type:'integer',minimum:1,maximum:5},url:{type:'string'},urls:{type:'array',minItems:1,maxItems:8,items:{type:'string'}},resolution:{type:'string',enum:['compact','standard']},string_name:{type:'string'},choices:{type:'array',maxItems:16,items:{type:'object',properties:{source:{type:'string'},kind:{type:'string',enum:['mcp','sdk','cli']},goal:{type:'string'}},required:['source','kind','goal'],additionalProperties:false}},selection_ids:{type:'array',items:{type:'string'},minItems:1,maxItems:16,uniqueItems:true},selection_id:{type:'string'},name:{type:'string'},arguments:{type:'object'},candidates:{type:'array',maxItems:16,items:{type:'object'}},lane:{type:'string',enum:['non_medical','medical']}},additionalProperties:false}};
19
+ export function createMcpServer({useClient,greet=introduce,strings=localStrings,models=configuredModels,onActivity}){
20
+ const server=new Server({name:'kloudy',version:'0.3.15'},{capabilities:{tools:{listChanged:true},prompts:{},resources:{}},instructions:'When the user says Kloudy or /kloudy, use operation introduce. Return the current project greeting. Do not repeatedly ask to install an already attached tool. Respect a no answer and the host permission ceiling. '+Object.values(ideGuidance).join(' ')});
21
+ const approvals=new Approvals(),hasUI=()=>getUiCapability(server.getClientCapabilities())?.mimeTypes?.includes(RESOURCE_MIME_TYPE);
22
+ server.setRequestHandler(ListResourcesRequestSchema,async()=>({resources:hasUI()?[{uri:approvalResource,name:'Kloudy action approval',mimeType:RESOURCE_MIME_TYPE}]:[]}));
23
+ server.setRequestHandler(ReadResourceRequestSchema,async request=>{if(!hasUI()||request.params.uri!==approvalResource)throw Error('Unsupported UI resource');return {contents:[{uri:approvalResource,mimeType:RESOURCE_MIME_TYPE,text:approvalHTML,_meta:{ui:{csp:{connectDomains:[],resourceDomains:[]}}}}]};});
24
+ async function projectRoot(){try{const roots=await server.listRoots();if(roots.roots?.[0]?.uri?.startsWith('file:'))return fileURLToPath(roots.roots[0].uri);}catch{}return process.env.CLAUDE_PROJECT_DIR||process.cwd();}
25
+ async function greeting(decision){let project=process.env.CLAUDE_PROJECT_DIR||process.cwd();try{const roots=await server.listRoots();if(roots.roots?.[0]?.uri?.startsWith('file:'))project=fileURLToPath(roots.roots[0].uri);}catch{}return greet({project,decision,hostAttached:true});}
26
+ server.setRequestHandler(ListPromptsRequestSchema,async()=>({prompts:[{name:'kloudy',description:'Welcome the current project to Kloudy and explain how to use it.'}]}));
27
+ server.setRequestHandler(GetPromptRequestSchema,async request=>{if(request.params.name!=='kloudy')throw Error('Unknown prompt');const result=await greeting();return {description:'Ask for Kloudy',messages:[{role:'assistant',content:welcomeContent(result)[0]}]};});
28
+ server.setRequestHandler(ListToolsRequestSchema,async()=>{
29
+ let selected;try{selected=await useClient(client=>client.state.selection?.tool);}catch{}
30
+ const meta=hasUI()?{_meta:{ui:{resourceUri:approvalResource}}}:{};return {tools:[{...gateway,...meta},...(selected?[{...selected,name:'selected_'+selected.name,...meta}]:[]),...(hasUI()?[approvalTool]:[])]};
31
+ });
32
+ server.setRequestHandler(CallToolRequestSchema,instrumentToolHandler(async request=>{
33
+ try{
34
+ const supplied=request.params.arguments||{};
35
+ if(request.params.name==='kloudy'&&supplied.operation==='models_list'){if(Object.keys(supplied).length!==1)throw Error('Model discovery uses host configuration only');const result=await models().list();return {content:[{type:'text',text:JSON.stringify(result)}],structuredContent:result};}
36
+
37
+ if(request.params.name==='kloudy'&&['convert','yield_list'].includes(supplied.operation)){const result=await (supplied.operation==='convert'?convertPage(supplied.url,{resolution:supplied.resolution}):convertPages(supplied.urls,{resolution:supplied.resolution}));return {content:[{type:'text',text:JSON.stringify(result)}],structuredContent:result};}
38
+ if(request.params.name==='kloudy_approval'){if(!hasUI())throw Error('This host has no inline approval UI');const result=await approvals.decide(supplied,useClient);return {content:[{type:'text',text:JSON.stringify(result)}],structuredContent:result};}
39
+ if(request.params.name==='kloudy'&&supplied.operation?.startsWith('strings_')){const store=strings({project:await projectRoot(),mode:'auto'}),op=supplied.operation.slice(8);let result;
40
+ if(op==='save')result=await store.save(supplied.string_name,supplied.choices);
41
+ else if(op==='call'){result=await store.call(supplied.string_name,supplied.name,supplied.arguments,{selectionId:supplied.selection_id});if(result?.state==='awaiting_approval'){const saved=(await store.list()).find(s=>s.name===supplied.string_name),route=saved?.activeRoutes?.find(r=>r.name===supplied.name&&(!supplied.selection_id||r.selection_id===supplied.selection_id));if(!route)throw Error('Selected route unavailable');return approvals.present(result,route.selection_id,work=>store.connect(route.source,route.kind,work));}}
42
+ else if(op==='list')result=await store.list();
43
+ else if(op==='use'){result=await store.use(supplied.string_name);}
44
+ else if(['keep','remove'].includes(op))result=await store[op](supplied.string_name);
45
+ else if(op==='prune')result=await store.prune();else throw Error('Unsupported strings operation');
46
+ return {content:[{type:'text',text:JSON.stringify(result)}],structuredContent:Array.isArray(result)?{strings:result}:result};}
47
+
48
+ if(request.params.name==='kloudy'&&(!supplied.operation||supplied.operation==='introduce'||supplied.operation==='decline'||supplied.operation==='ask'&&isAttachIntent(supplied.goal))){const result=await greeting(supplied.operation==='decline'?'no':undefined);return {content:welcomeContent(result),structuredContent:result};}
49
+ const result=await useClient(async client=>{
50
+ const input=request.params.arguments||{};
51
+ if(request.params.name.startsWith('selected_'))return client.call(request.params.name.slice(9),input);
52
+ if(request.params.name!=='kloudy')throw new Error('Unknown tool');
53
+ if(input.operation==='room'){if(!client.room)throw Error('Room operations require an existing scoped HTTPK connection');const params=roomParams(input.room_operation,input.room);return roomResult(input.room_operation,await client.room(input.room_operation,params),params);}
54
+ if(input.operation==='conversation'){if(!client.conversation)throw Error('Conversation sync requires a scoped HTTPK connection');return client.conversation(input.conversation_operation,input.conversation);}
55
+ if(input.operation==='host_interface')return client.negotiateHost();
56
+ if(input.operation==='action')return client.action(input.action,input.inputs,{idempotencyKey:input.idempotency_key});
57
+ if(input.operation==='wallet_connection')return client.walletConnection(input.chain);
58
+ if(input.operation==='hosting')return client.hosting({budgetUsdMicros:input.budget_usd_micros});
59
+ if(input.operation==='wallet'){if(!client.wallet)throw Error('Wallet previews require HTTPK');return client.wallet({operation:input.wallet_operation||'balance',amount_usd:input.amount_usd});}
60
+ if(input.operation==='query'){if(!client.query)throw Error('Query needs an HTTPK connection');return client.query(input.goal,{scope:input.scope,limit:input.limit});}
61
+ if(input.operation==='ask')return client.ask(input.goal,{candidates:input.candidates||[],lane:input.lane||'non_medical'});
62
+ if(input.operation==='assemble'){if(!client.assemble)throw Error('Assembly requires an HTTPK connection');return client.assemble(input.selection_ids);}
63
+ if(input.operation==='call'){if(!client.assemble||!input.selection_id)throw Error('Exact HTTPK selection required');return client.call(input.name,input.arguments,{selectionId:input.selection_id});}
64
+ if(input.operation==='status')return client.status();
65
+ if(input.operation==='cancel')return client.decide('cancel');
66
+ throw new Error('Unsupported operation');
67
+ });
68
+ // Prompt the host to fetch the newly selected singular tool, not a server dump.
69
+ server.sendToolListChanged().catch(()=>{});
70
+ if(result?.state==='awaiting_approval')return approvals.present(result,result.selection_id);
71
+ return {content:[{type:'text',text:JSON.stringify(result)}],structuredContent:result};
72
+ }catch(error){return {isError:true,content:[{type:'text',text:JSON.stringify({error:error.code||'request_failed',message:error.code?error.message:'The tool request could not be completed.'})}]};}
73
+ },onActivity));
74
+ return server;
75
+ }
76
+ export async function serveMcp(connection){const server=createMcpServer({useClient:work=>withConnection(connection,work,{mode:'auto'}),greet:options=>introduce({...options,connection})});await server.connect(new StdioServerTransport());return server;}
package/src/remote.mjs CHANGED
@@ -1,89 +1,117 @@
1
- import {readFileSync} from 'node:fs';
2
- const packageVersion=JSON.parse(readFileSync(new URL('../package.json',import.meta.url),'utf8')).version;
3
- import {Client} from '@modelcontextprotocol/sdk/client/index.js';
4
- import {StreamableHTTPClientTransport} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
5
- import {Server} from '@modelcontextprotocol/sdk/server/index.js';
6
- import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js';
7
- import {ListToolsRequestSchema,CallToolRequestSchema,ListResourcesRequestSchema,ReadResourceRequestSchema,ListResourceTemplatesRequestSchema,ToolListChangedNotificationSchema,ResourceListChangedNotificationSchema} from '@modelcontextprotocol/sdk/types.js';
8
- import {REMOTE} from './editor-install.mjs';
9
- import {autoupdateEnabled} from './autoupdate.mjs';
10
- import {chooserResponse,pendingChooserResponse,pendingChooserTool} from './chooser-link.mjs';
11
- import {WebsiteSelectionReturn,websiteReturnTool} from './website-return.mjs';
12
- const transports=new WeakMap();
13
- // This proxy can relay Apps rendering, but cannot service sampling, roots or elicitation.
14
- export function forwardedCapabilities(value={}){
15
- const ui=value.experimental?.['io.modelcontextprotocol/ui'];
16
- const mimeTypes=Array.isArray(ui?.mimeTypes)?ui.mimeTypes.filter(x=>x==='text/html;profile=mcp-app'):[];
17
- return mimeTypes.length?{experimental:{'io.modelcontextprotocol/ui':{mimeTypes:[...new Set(mimeTypes)]}}}:{};
18
- }
19
- export async function connectRemote({key=process.env.KLOUDY_MCP_TOKEN,fetcher=fetch,capabilities={}}={}){
20
- if(key!==undefined&&!/^\S{20,512}$/.test(key))throw Error('Invalid key');
21
- const client=new Client({name:'kloudy',version:packageVersion},{capabilities:forwardedCapabilities(capabilities)});
22
- const transport=new StreamableHTTPClientTransport(new URL(REMOTE),{requestInit:{headers:key?{Authorization:'Bearer '+key}:{},redirect:'error'},fetch:(url,options)=>fetcher(url,{...options,redirect:'error'}),reconnectionOptions:{maxRetries:0}});
23
- transports.set(client,transport);
24
- try{await client.connect(transport,{timeout:25000});return client;}catch(e){await client.close().catch(()=>{});throw e;}
25
- }
26
- export async function askRemote(question,options){
27
- if(typeof question!=='string'||!question.trim()||question.length>1000)throw Error('Use a question of 1–1000 characters');
28
- const client=await connectRemote(options);
29
- try{return await client.callTool({name:'kloudy',arguments:{operation:'ask',goal:question}},undefined,{timeout:25000});}finally{await client.close();}
30
- }
31
- export async function serveRemote(options={}){
32
- const connect=options.connect??connectRemote,enabled=autoupdateEnabled(options.autoupdate??process.env.KLOUDY_MCP_AUTOUPDATE);
33
- let remote=await connect({...options,capabilities:{}}),ready=Promise.resolve(),closed=false,reconnecting,pendingChooser,requestGoal;
34
- const attachment=options.websiteReturn??(options.connect?null:new WebsiteSelectionReturn());
35
- const upstream=remote.getServerCapabilities(),capabilities={};
36
- if(upstream.tools)capabilities.tools=enabled&&upstream.tools.listChanged?{listChanged:true}:{};
37
- if(upstream.resources)capabilities.resources=enabled&&upstream.resources.listChanged?{listChanged:true}:{};
38
- const server=new Server({name:'kloudy',version:packageVersion},{capabilities,instructions:'Use Kloudy for discovery and data-only visual setup choices. Call the card tool explicitly for its UI. Hosts without Apps use named choices, sources and local assembly instructions. Existing authorization remains required for account actions.'});
39
- function forward(client){
40
- if(capabilities.tools?.listChanged)client.setNotificationHandler(ToolListChangedNotificationSchema,()=>server.sendToolListChanged());
41
- if(capabilities.resources?.listChanged)client.setNotificationHandler(ResourceListChangedNotificationSchema,()=>server.sendResourceListChanged());
42
- }
43
- forward(remote);
44
- server.oninitialized=()=>{
45
- const actual=forwardedCapabilities(server.getClientCapabilities());
46
- if(!Object.keys(actual).length)return;
47
- ready=(async()=>{const next=await connect({...options,capabilities:actual});if(closed){await next.close();return;}const previous=remote;remote=next;forward(next);await previous.close();})();
48
- // Requests still receive negotiation errors; avoid an unhandled rejection before the first request.
49
- void ready.catch(()=>{});
50
- };
51
- const invoke=async(method,params)=>{
52
- await ready;const current=remote,run=client=>method==='callTool'?client.callTool(params,undefined,{timeout:25000}):client[method](params);
53
- try{return await run(current);}catch(error){
54
- // Preserve 0.4.5's opt-in recovery: only an established HTTP session's explicit 404 permits one retry.
55
- if(!enabled||error.code!==404||!transports.get(current)?.sessionId)throw error;
56
- if(remote===current){reconnecting??=(async()=>{const next=await connect({...options,capabilities:forwardedCapabilities(server.getClientCapabilities())});if(closed){await next.close();throw error;}remote=next;forward(next);await current.close().catch(()=>{});if(capabilities.tools?.listChanged)await server.sendToolListChanged();if(capabilities.resources?.listChanged)await server.sendResourceListChanged();})().finally(()=>{reconnecting=undefined;});await reconnecting;}
57
- return run(remote);
58
- }
59
- };
60
- if(capabilities.tools){
61
- server.setRequestHandler(ListToolsRequestSchema,async req=>{
62
- const result=await invoke('listTools',req.params);
63
- return {...result,tools:[...result.tools.map(tool=>tool.name==='show_visual_setup_choices'?{...tool,inputSchema:{...tool.inputSchema,properties:{...tool.inputSchema.properties,goal:{type:'string',minLength:1,maxLength:1000,description:'Presentation request retained in this MCP process; never sent as a credential or URL.'}}}}:tool),...(result.tools.some(t=>t.name===pendingChooserTool.name)?[]:[pendingChooserTool]),websiteReturnTool]};
64
- });
65
- server.setRequestHandler(CallToolRequestSchema,async req=>{
66
- if(req.params.name===websiteReturnTool.name){const args=req.params.arguments||{};if(Object.keys(args).some(k=>k!=='waitSeconds'))throw Error('Return lookup accepts a bounded wait only');const result=attachment?await attachment.receive(args):{state:'unavailable'};if(result.state?.startsWith('received')&&pendingChooser)pendingChooser={...pendingChooser,structuredContent:{...pendingChooser.structuredContent,authenticatedReturn:result}};return {content:[{type:'text',text:JSON.stringify(result)}],structuredContent:result};}
67
- if(req.params.name===pendingChooserTool.name){if(Object.keys(req.params.arguments||{}).length)throw Error('Pending chooser lookup takes no identity or credential fields');return pendingChooserResponse(pendingChooser);}
68
- if(req.params.name!=='show_visual_setup_choices')return invoke('callTool',req.params);
69
- const argumentsValue={...(req.params.arguments||{})};
70
- let nextGoal=requestGoal;
71
- if(Object.hasOwn(argumentsValue,'goal')){if(typeof argumentsValue.goal!=='string'||!argumentsValue.goal.trim()||argumentsValue.goal.length>1000)throw Error('Use a presentation request of 1-1000 characters');nextGoal=argumentsValue.goal;delete argumentsValue.goal;}
72
- const result=await invoke('callTool',{...req.params,arguments:argumentsValue});
73
- const decorated=chooserResponse(result,{apps:!!forwardedCapabilities(server.getClientCapabilities()).experimental,goal:nextGoal});
74
- if(!decorated.isError&&nextGoal&&attachment){
75
- try{const link=await attachment.open(nextGoal),priorUrl=decorated.structuredContent.chooser.url;decorated.content=decorated.content.map(c=>c.type==='text'&&c.text.startsWith('Website chooser: ')?{...c,text:c.text.replace(priorUrl,link.chooserUrl)}:c);decorated.structuredContent.chooser={...decorated.structuredContent.chooser,url:link.chooserUrl,returnMethod:'paired-encrypted-website-selection'};decorated.content.push({type:'text',text:'Open '+link.chooserUrl+' in your signed-in account. Confirm the waiting IDE code '+link.pairCode+' and the existing project. Then return the selection; receive_website_selection waits on this same MCP connection. The locator grants no account authority.'});decorated.structuredContent.websiteReturn={...link,extensionRequired:false,engineAuthority:false};}
76
- catch{decorated.structuredContent.websiteReturn={available:false,manualManifestFallback:true};}
77
- }
78
- if(!decorated.isError){requestGoal=nextGoal;pendingChooser=decorated;}return decorated;
79
- });
80
- }
81
- // Resource delivery is independent of notification/autoupdate opt-in.
82
- if(capabilities.resources){
83
- server.setRequestHandler(ListResourcesRequestSchema,req=>invoke('listResources',req.params));
84
- server.setRequestHandler(ReadResourceRequestSchema,req=>invoke('readResource',req.params));
85
- server.setRequestHandler(ListResourceTemplatesRequestSchema,req=>invoke('listResourceTemplates',req.params));
86
- }
87
- server.onclose=()=>{closed=true;attachment?.close?.();void ready.catch(()=>{}).then(()=>remote.close());};
88
- await server.connect(options.transport??new StdioServerTransport());return server;
89
- }
1
+ import {readFileSync} from 'node:fs';
2
+ import {lstat,open} from 'node:fs/promises';
3
+ const packageVersion=JSON.parse(readFileSync(new URL('../package.json',import.meta.url),'utf8')).version;
4
+ import {Client} from '@modelcontextprotocol/sdk/client/index.js';
5
+ import {StreamableHTTPClientTransport} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
6
+ import {Server} from '@modelcontextprotocol/sdk/server/index.js';
7
+ import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js';
8
+ import {ListToolsRequestSchema,CallToolRequestSchema,ListResourcesRequestSchema,ReadResourceRequestSchema,ListResourceTemplatesRequestSchema,ToolListChangedNotificationSchema,ResourceListChangedNotificationSchema} from '@modelcontextprotocol/sdk/types.js';
9
+ import {REMOTE} from './editor-install.mjs';
10
+ import {autoupdateEnabled} from './autoupdate.mjs';
11
+ import {chooserResponse,pendingChooserResponse,pendingChooserTool} from './chooser-link.mjs';
12
+ import {WebsiteSelectionReturn,websiteReturnTool} from './website-return.mjs';
13
+ const transports=new WeakMap();
14
+ // This proxy can relay Apps rendering, but cannot service sampling, roots or elicitation.
15
+ export function forwardedCapabilities(value={}){
16
+ const ui=value.experimental?.['io.modelcontextprotocol/ui'];
17
+ const mimeTypes=Array.isArray(ui?.mimeTypes)?ui.mimeTypes.filter(x=>x==='text/html;profile=mcp-app'):[];
18
+ return mimeTypes.length?{experimental:{'io.modelcontextprotocol/ui':{mimeTypes:[...new Set(mimeTypes)]}}}:{};
19
+ }
20
+ async function remoteCredential(options){
21
+ const key=Object.hasOwn(options,'key')?options.key:process.env.KLOUDY_MCP_TOKEN;
22
+ const connection=Object.hasOwn(options,'connection')?options.connection:process.env.KLOUDY_CONNECTION;
23
+ if(connection===undefined){if(key!==undefined&&!/^\S{20,512}$/.test(key))throw Error('Invalid key');return {key};}
24
+ if(key!==undefined)throw Error('Choose one remote credential source.');
25
+ try{
26
+ if(typeof connection!=='string'||!connection.trim()||connection.length>4096||connection.includes('\0'))throw Error();
27
+ if(!(await lstat(connection)).isFile())throw Error();
28
+ const handle=await open(connection,'r');let value;
29
+ try{
30
+ const stat=await handle.stat();if(!stat.isFile()||stat.size>16384)throw Error();
31
+ const bytes=Buffer.alloc(16385);let length=0;
32
+ while(length<bytes.length){const {bytesRead}=await handle.read(bytes,length,bytes.length-length,null);if(!bytesRead)break;length+=bytesRead;}
33
+ if(length>16384)throw Error();
34
+ value=JSON.parse(new TextDecoder('utf-8',{fatal:true}).decode(bytes.subarray(0,length)));
35
+ }finally{await handle.close();}
36
+ if(!value||Array.isArray(value)||typeof value!=='object'||value.endpoint!==REMOTE||
37
+ typeof value.access_token!=='string'||! /^[\x21-\x7e]{20,128}$/.test(value.access_token)||
38
+ !Number.isSafeInteger(value.expires_at)||value.expires_at<=Math.floor(Date.now()/1000)||
39
+ Object.hasOwn(value,'credential')&&value.credential!==value.access_token)throw Error();
40
+ return {key:value.access_token,expires:value.expires_at};
41
+ }catch{throw Error('Private remote connection is unavailable or invalid.');}
42
+ }
43
+ const redact=(value,key)=>!key?value:typeof value==='string'?value.replaceAll(key,'[redacted]'):Array.isArray(value)?value.map(x=>redact(x,key)):value&&typeof value==='object'?Object.fromEntries(Object.entries(value).map(([k,v])=>[k,redact(v,key)])):value;
44
+ async function connectUsing(credential,fetcher,capabilities={}){
45
+ if(credential.expires!==undefined&&credential.expires<=Math.floor(Date.now()/1000))throw Error('Private remote connection expired.');
46
+ const client=new Client({name:'kloudy',version:packageVersion},{capabilities:forwardedCapabilities(capabilities)});
47
+ const transport=new StreamableHTTPClientTransport(new URL(REMOTE),{requestInit:{headers:credential.key?{Authorization:'Bearer '+credential.key}:{},redirect:'error'},fetch:(url,options)=>fetcher(url,{...options,redirect:'error'}),reconnectionOptions:{maxRetries:0}});
48
+ transports.set(client,transport);
49
+ try{await client.connect(transport,{timeout:25000});return client;}catch(e){await client.close().catch(()=>{});throw e;}
50
+ }
51
+ export async function connectRemote(options={}){return connectUsing(await remoteCredential(options),options.fetcher??fetch,options.capabilities);}
52
+ export async function askRemote(question,options){
53
+ if(typeof question!=='string'||!question.trim()||question.length>1000)throw Error('Use a question of 1–1000 characters');
54
+ options??={};const credential=await remoteCredential(options),client=await connectUsing(credential,options.fetcher??fetch);
55
+ try{return redact(await client.callTool({name:'kloudy',arguments:{operation:'ask',goal:question}},undefined,{timeout:25000}),credential.key);}finally{await client.close();}
56
+ }
57
+ export async function serveRemote(options={}){
58
+ const credential=options.connect?null:await remoteCredential(options);
59
+ // One credential identity for capability negotiation and every session reconnect.
60
+ const connect=options.connect??(next=>connectUsing(credential,next.fetcher??fetch,next.capabilities)),enabled=autoupdateEnabled(options.autoupdate??process.env.KLOUDY_MCP_AUTOUPDATE);
61
+ let remote=await connect({...options,capabilities:{}}),ready=Promise.resolve(),closed=false,reconnecting,pendingChooser,requestGoal;
62
+ const attachment=options.websiteReturn??(options.connect?null:new WebsiteSelectionReturn());
63
+ const upstream=remote.getServerCapabilities(),capabilities={};
64
+ if(upstream.tools)capabilities.tools=enabled&&upstream.tools.listChanged?{listChanged:true}:{};
65
+ if(upstream.resources)capabilities.resources=enabled&&upstream.resources.listChanged?{listChanged:true}:{};
66
+ const server=new Server({name:'kloudy',version:packageVersion},{capabilities,instructions:'Use Kloudy for discovery and data-only visual setup choices. Call the card tool explicitly for its UI. Hosts without Apps use named choices, sources and local assembly instructions. Existing authorization remains required for account actions.'});
67
+ function forward(client){
68
+ if(capabilities.tools?.listChanged)client.setNotificationHandler(ToolListChangedNotificationSchema,()=>server.sendToolListChanged());
69
+ if(capabilities.resources?.listChanged)client.setNotificationHandler(ResourceListChangedNotificationSchema,()=>server.sendResourceListChanged());
70
+ }
71
+ forward(remote);
72
+ server.oninitialized=()=>{
73
+ const actual=forwardedCapabilities(server.getClientCapabilities());
74
+ if(!Object.keys(actual).length)return;
75
+ ready=(async()=>{const next=await connect({...options,capabilities:actual});if(closed){await next.close();return;}const previous=remote;remote=next;forward(next);await previous.close();})();
76
+ // Requests still receive negotiation errors; avoid an unhandled rejection before the first request.
77
+ void ready.catch(()=>{});
78
+ };
79
+ const invoke=async(method,params)=>{
80
+ await ready;const current=remote,run=async client=>redact(await (method==='callTool'?client.callTool(params,undefined,{timeout:25000}):client[method](params)),credential?.key);
81
+ try{return await run(current);}catch(error){
82
+ // Preserve 0.4.5's opt-in recovery: only an established HTTP session's explicit 404 permits one retry.
83
+ if(!enabled||error.code!==404||!transports.get(current)?.sessionId)throw error;
84
+ if(remote===current){reconnecting??=(async()=>{const next=await connect({...options,capabilities:forwardedCapabilities(server.getClientCapabilities())});if(closed){await next.close();throw error;}remote=next;forward(next);await current.close().catch(()=>{});if(capabilities.tools?.listChanged)await server.sendToolListChanged();if(capabilities.resources?.listChanged)await server.sendResourceListChanged();})().finally(()=>{reconnecting=undefined;});await reconnecting;}
85
+ return run(remote);
86
+ }
87
+ };
88
+ if(capabilities.tools){
89
+ server.setRequestHandler(ListToolsRequestSchema,async req=>{
90
+ const result=await invoke('listTools',req.params);
91
+ return {...result,tools:[...result.tools.map(tool=>tool.name==='show_visual_setup_choices'?{...tool,inputSchema:{...tool.inputSchema,properties:{...tool.inputSchema.properties,goal:{type:'string',minLength:1,maxLength:1000,description:'Presentation request retained in this MCP process; never sent as a credential or URL.'}}}}:tool),...(result.tools.some(t=>t.name===pendingChooserTool.name)?[]:[pendingChooserTool]),websiteReturnTool]};
92
+ });
93
+ server.setRequestHandler(CallToolRequestSchema,async req=>{
94
+ if(req.params.name===websiteReturnTool.name){const args=req.params.arguments||{};if(Object.keys(args).some(k=>k!=='waitSeconds'))throw Error('Return lookup accepts a bounded wait only');const result=attachment?await attachment.receive(args):{state:'unavailable'};if(result.state?.startsWith('received')&&pendingChooser)pendingChooser={...pendingChooser,structuredContent:{...pendingChooser.structuredContent,authenticatedReturn:result}};return {content:[{type:'text',text:JSON.stringify(result)}],structuredContent:result};}
95
+ if(req.params.name===pendingChooserTool.name){if(Object.keys(req.params.arguments||{}).length)throw Error('Pending chooser lookup takes no identity or credential fields');return pendingChooserResponse(pendingChooser);}
96
+ if(req.params.name!=='show_visual_setup_choices')return invoke('callTool',req.params);
97
+ const argumentsValue={...(req.params.arguments||{})};
98
+ let nextGoal=requestGoal;
99
+ if(Object.hasOwn(argumentsValue,'goal')){if(typeof argumentsValue.goal!=='string'||!argumentsValue.goal.trim()||argumentsValue.goal.length>1000)throw Error('Use a presentation request of 1-1000 characters');nextGoal=argumentsValue.goal;delete argumentsValue.goal;}
100
+ const result=await invoke('callTool',{...req.params,arguments:argumentsValue});
101
+ const decorated=chooserResponse(result,{apps:!!forwardedCapabilities(server.getClientCapabilities()).experimental,goal:nextGoal});
102
+ if(!decorated.isError&&nextGoal&&attachment){
103
+ try{const link=await attachment.open(nextGoal),priorUrl=decorated.structuredContent.chooser.url;decorated.content=decorated.content.map(c=>c.type==='text'&&c.text.startsWith('Website chooser: ')?{...c,text:c.text.replace(priorUrl,link.chooserUrl)}:c);decorated.structuredContent.chooser={...decorated.structuredContent.chooser,url:link.chooserUrl,returnMethod:'paired-encrypted-website-selection'};decorated.content.push({type:'text',text:'Open '+link.chooserUrl+' in your signed-in account. Confirm the waiting IDE code '+link.pairCode+' and the existing project. Then return the selection; receive_website_selection waits on this same MCP connection. The locator grants no account authority.'});decorated.structuredContent.websiteReturn={...link,extensionRequired:false,engineAuthority:false};}
104
+ catch{decorated.structuredContent.websiteReturn={available:false,manualManifestFallback:true};}
105
+ }
106
+ if(!decorated.isError){requestGoal=nextGoal;pendingChooser=decorated;}return decorated;
107
+ });
108
+ }
109
+ // Resource delivery is independent of notification/autoupdate opt-in.
110
+ if(capabilities.resources){
111
+ server.setRequestHandler(ListResourcesRequestSchema,req=>invoke('listResources',req.params));
112
+ server.setRequestHandler(ReadResourceRequestSchema,req=>invoke('readResource',req.params));
113
+ server.setRequestHandler(ListResourceTemplatesRequestSchema,req=>invoke('listResourceTemplates',req.params));
114
+ }
115
+ server.onclose=()=>{closed=true;attachment?.close?.();void ready.catch(()=>{}).then(()=>remote.close());};
116
+ await server.connect(options.transport??new StdioServerTransport());return server;
117
+ }
package/src/rooms.mjs ADDED
@@ -0,0 +1,86 @@
1
+ // The existing engine owns membership, grants, persistence and replies.
2
+ export const roomOperations=['readiness','create','info','send','receipt','inbox','reply','interrupt','close'];
3
+ const id={type:'string',pattern:'^[A-Za-z0-9_.:-]{1,80}$'};
4
+ const member={type:'object',required:['id','label','client_id'],additionalProperties:false,properties:{id,label:{type:'string',minLength:1,maxLength:120},client_id:id}};
5
+ const readinessMember={type:'object',required:['id','client_id'],additionalProperties:false,properties:{id,client_id:id}};
6
+ // Keep the existing conservative public four-participant ceiling. This client
7
+ // extension does not assert that a hosted ten-participant rollout occurred.
8
+ const messageSchema={type:'object',required:['project','room_id'],additionalProperties:false,properties:{
9
+ project:id,room_id:id,command_id:id,request_id:id,reply_id:id,participant_id:id,
10
+ title:{type:'string',minLength:1,maxLength:120},
11
+ participants:{type:'array',minItems:1,maxItems:4,items:member},
12
+ recipients:{type:'array',minItems:1,maxItems:4,uniqueItems:true,items:id},
13
+ generation:{type:'integer',minimum:0},after_sequence:{type:'integer',minimum:0},
14
+ text:{type:'string',minLength:1,maxLength:4000},mode:{type:'string',enum:['direct','stored']},
15
+ }};
16
+ export const roomSchema={anyOf:[messageSchema,{type:'object',required:['project','participants'],additionalProperties:false,properties:{project:id,participants:{type:'array',minItems:1,maxItems:4,items:readinessMember}}}]};
17
+ export const roomDescription='Uses existing room grants; no enrollment or provider call is implied. readiness needs only project and participants (id, existing client_id); it checks mailbox authority, never provider ingress. Other operations need project and room_id. create adds command_id, title, participants (id, label, existing client_id). send adds request_id, generation, recipients, text, mode. receipt adds request_id. inbox adds participant_id, after_sequence. reply adds participant_id, request_id, reply_id, text. interrupt/close add command_id, generation. info adds nothing. Reuse IDs after uncertain delivery; never invent a reply or treat queued as delivered.';
18
+ const bad=()=>{throw Error('invalid_room_request');};
19
+ const plain=value=>value&&typeof value==='object'&&!Array.isArray(value);
20
+ const exact=(value,keys)=>{if(!plain(value)||Object.keys(value).length!==keys.length||keys.some(key=>!Object.hasOwn(value,key)))bad();};
21
+ const identifier=value=>typeof value==='string'&&value.length>=1&&value.length<=80&&!/[^A-Za-z0-9_.:-]/.test(value);
22
+ const integer=value=>Number.isSafeInteger(value)&&value>=0;
23
+ const text=(value,max)=>typeof value==='string'&&value.length<=max*2&&value.trim().length>0&&[...value].length<=max&&Buffer.byteLength(value,'utf8')<=6000&&Buffer.from(value).toString('utf8')===value;
24
+ const fields={readiness:['participants'],create:['command_id','title','participants'],info:[],send:['request_id','generation','recipients','text','mode'],receipt:['request_id'],inbox:['participant_id','after_sequence'],reply:['participant_id','request_id','reply_id','text'],interrupt:['command_id','generation'],close:['command_id','generation']};
25
+ const snapshot=value=>{const encoded=JSON.stringify(value);if(Buffer.byteLength(encoded,'utf8')>7600)bad();return JSON.parse(encoded);};
26
+ export function roomParams(operation,value){
27
+ if(!roomOperations.includes(operation))bad();
28
+ exact(value,['project',...(operation==='readiness'?[]:['room_id']),...fields[operation]]);
29
+ for(const key of ['project','room_id','command_id','request_id','reply_id','participant_id'])if(Object.hasOwn(value,key)&&!identifier(value[key]))bad();
30
+ for(const key of ['generation','after_sequence'])if(Object.hasOwn(value,key)&&!integer(value[key]))bad();
31
+ if(Object.hasOwn(value,'title')&&!text(value.title,120)||Object.hasOwn(value,'text')&&!text(value.text,4000))bad();
32
+ if(operation==='send'&&!['direct','stored'].includes(value.mode))bad();
33
+ if(Object.hasOwn(value,'participants')){
34
+ if(!Array.isArray(value.participants)||value.participants.length<1||value.participants.length>4)bad();
35
+ for(const participant of value.participants){
36
+ exact(participant,operation==='readiness'?['id','client_id']:['id','label','client_id']);
37
+ if(!identifier(participant.id)||!identifier(participant.client_id)||operation==='create'&&!text(participant.label,120))bad();
38
+ }
39
+ if(new Set(value.participants.map(x=>x.id)).size!==value.participants.length||new Set(value.participants.map(x=>x.client_id)).size!==value.participants.length)bad();
40
+ }
41
+ if(Object.hasOwn(value,'recipients')&&(!Array.isArray(value.recipients)||value.recipients.length<1||value.recipients.length>4||value.recipients.some(x=>!identifier(x))||new Set(value.recipients).size!==value.recipients.length))bad();
42
+ return snapshot(value);
43
+ }
44
+
45
+ export function roomResult(operation,value,input){
46
+ if(operation==='reply'){
47
+ // A matched JSON-RPC envelope alone is not acknowledgement of this reply.
48
+ // Core deliberately returns no reply text or proprietary assistant identity.
49
+ if(!plain(value)||Object.keys(value).length!==4||
50
+ !['request_id','participant_id','state','replayed'].every(key=>Object.hasOwn(value,key))||
51
+ value.request_id!==input.request_id||value.participant_id!==input.participant_id||
52
+ !['replied','late_reply'].includes(value.state)||typeof value.replayed!=='boolean')
53
+ throw Error('invalid_room_response');
54
+ return {request_id:value.request_id,participant_id:value.participant_id,state:value.state,replayed:value.replayed};
55
+ }
56
+ if(operation!=='readiness')return value;
57
+ if(!plain(value)||value.version!==1||value.project!==input.project||value.authority_verified!==true||value.provider_ingress_verified!==false||
58
+ !Array.isArray(value.participants)||value.participants.length!==input.participants.length||
59
+ value.participants.some((participant,index)=>!plain(participant)||Object.keys(participant).length!==1||participant.id!==input.participants[index].id))
60
+ throw Error('invalid_room_response');
61
+ return {version:value.version,project:value.project,authority_verified:value.authority_verified,
62
+ provider_ingress_verified:value.provider_ingress_verified,participants:value.participants.map(({id})=>({id}))};
63
+ }
64
+
65
+ /** Explicit authenticated SDK controls only. Callback URLs remain data for the
66
+ * engine's authoritative DNS/TLS/SSRF verification; this client never fetches them. */
67
+ export function roomEventParams(operation,value={}){
68
+ if(operation==='list'){exact(value,[]);return {};}
69
+ if(!['subscribe','unsubscribe'].includes(operation)||!plain(value))bad();
70
+ exact(value,['name','arguments','delivery',...(operation==='subscribe'&&Object.hasOwn(value,'cursor')?['cursor']:[]),...(operation==='subscribe'&&Object.hasOwn(value,'ttlMs')?['ttlMs']:[])]);
71
+ if(value.name!=='message.created')bad();
72
+ exact(value.arguments,['project','room_id','participant_id']);
73
+ if(Object.values(value.arguments).some(x=>!identifier(x)))bad();
74
+ exact(value.delivery,['mode','url',...(operation==='subscribe'?['secret']:[])]);
75
+ const address=value.delivery.url;let url;try{url=new URL(address);}catch{bad();}
76
+ if(value.delivery.mode!=='webhook'||typeof address!=='string'||address.length>2048||/[^\x21-\x7e]/.test(address)||url.protocol!=='https:'||url.username||url.password||url.hash)bad();
77
+ if(operation==='subscribe'){
78
+ if(Object.hasOwn(value,'cursor')&&value.cursor!==null)bad();
79
+ if(Object.hasOwn(value,'ttlMs')&&(!Number.isSafeInteger(value.ttlMs)||value.ttlMs<1||value.ttlMs>3_600_000))bad();
80
+ const secret=value.delivery.secret;
81
+ if(typeof secret!=='string'||!secret.startsWith('whsec_')||secret.length>100)bad();
82
+ const encoded=secret.slice(6),bytes=Buffer.from(encoded,'base64');
83
+ if(bytes.length<24||bytes.length>64||bytes.toString('base64')!==encoded)bad();
84
+ }
85
+ return snapshot(value);
86
+ }
@@ -1,18 +1,18 @@
1
- // Delivery keys protect inert presentation data. They never authorize engine calls.
2
- import {generateKeyPairSync,createPublicKey,diffieHellman,hkdfSync,createDecipheriv,createHash} from 'node:crypto';
3
- const version='kloudy.website-return/1',hash=s=>createHash('sha256').update(s).digest('hex'),origin='https://kloudy.ai',root=origin+'/api/customer/visual-chooser/return/';
4
- export const websiteReturnTool={name:'receive_website_selection',description:'Wait for the exact inert selection returned by the signed-in website to this MCP connection. No IDE extension, website cookie, account token, model or grant is needed. Use after showing the chooser link. A waiting result is safe to check again; do not repeat mutations after uncertain outcomes.',inputSchema:{type:'object',additionalProperties:false,properties:{waitSeconds:{type:'integer',minimum:0,maximum:45}}},annotations:{readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false}};
5
- export class WebsiteSelectionReturn{
6
- constructor({fetcher=fetch,clock=()=>Date.now(),sleep=ms=>new Promise(r=>setTimeout(r,ms))}={}){Object.assign(this,{fetcher,clock,sleep});this.keys=generateKeyPairSync('x25519');this.publicKey=this.keys.publicKey.export({format:'jwk'}).x;this.sequence=0;this.receipt=null;}
7
- async request(action,input){const response=await this.fetcher(root+action,{method:'POST',headers:{origin,'content-type':'application/json'},body:JSON.stringify(input),redirect:'error',signal:AbortSignal.timeout(10000)});const text=await response.text();if(text.length>300000)throw Error('Return response exceeds the bounded manifest');const value=JSON.parse(text);if(!response.ok)throw Error(typeof value.error==='string'?value.error:'Website return unavailable');return value;}
8
- async open(goal){if(this.link)return this.link;if(typeof goal!=='string'||!goal.trim()||goal.length>1000)throw Error('Use a presentation goal up to 1000 characters');const result=await this.request('open',{publicKey:this.publicKey,goal});if(result.version!==version||!/^[a-f0-9]{32}$/.test(result.id)||result.linkIsAuthorization!==false||result.chooserUrl!==origin+'/api/customer/visual-chooser/start#return='+result.id||result.pairCode!==hash(result.id+':'+this.publicKey).slice(0,8).toUpperCase())throw Error('Unexpected website return attachment');this.link=result;return result;}
9
- decrypt(envelope){
10
- if(envelope?.version!==version||envelope.id!==this.link?.id||envelope.sequence!==this.sequence+1||!/^[A-Za-z0-9_-]{43}$/.test(envelope.serverPublicKey)||!/^[A-Za-z0-9_-]{16}$/.test(envelope.iv)||!/^[A-Za-z0-9_-]{22}$/.test(envelope.tag)||typeof envelope.ciphertext!=='string'||envelope.ciphertext.length>270000)throw Error('Unexpected return envelope');
11
- const aad=JSON.stringify([version,this.link.id,hash(this.publicKey),envelope.sequence]),peer=createPublicKey({key:{kty:'OKP',crv:'X25519',x:envelope.serverPublicKey},format:'jwk'}),secret=diffieHellman({privateKey:this.keys.privateKey,publicKey:peer}),key=Buffer.from(hkdfSync('sha256',secret,Buffer.alloc(0),Buffer.from(aad),32)),decipher=createDecipheriv('aes-256-gcm',key,Buffer.from(envelope.iv,'base64url'));decipher.setAAD(Buffer.from(aad));decipher.setAuthTag(Buffer.from(envelope.tag,'base64url'));const payload=JSON.parse(Buffer.concat([decipher.update(Buffer.from(envelope.ciphertext,'base64url')),decipher.final()]).toString()),s=payload.result?.structuredContent,m=s?.selectedManifest;
12
- if(payload.version!==version||payload.accountProjectVerified!==true||payload.executionAllowed!==false||typeof payload.conversationId!=='string'||typeof payload.receiptId!=='string'||payload.projectId!==m?.projectId||payload.projectId!==s?.projectState?.projectId||payload.revision!==s.revision||m.revision!==s.revision||s.executed!==false||m.executionAllowed!==false||m.authorizesSpending!==false||m.authorizesInstallation!==false)throw Error('Website selection is not an exact inert project manifest');
13
- if(this.receipt&&(payload.projectId!==this.receipt.projectId||payload.conversationId!==this.receipt.conversationId||s.projectState.originalGoal!==this.receipt.result.structuredContent.projectState.originalGoal))throw Error('Account project or original conversation changed');
14
- if(typeof payload.ackNonce!=='string'||!/^[A-Za-z0-9_-]{43}$/.test(payload.ackNonce))throw Error('Missing exact delivery acknowledgment');this.pendingAck={id:this.link.id,sequence:envelope.sequence,nonce:payload.ackNonce};delete payload.ackNonce;this.sequence=envelope.sequence;this.receipt=payload;return payload;
15
- }
16
- async receive({waitSeconds=45}={}){if(!Number.isInteger(waitSeconds)||waitSeconds<0||waitSeconds>45)throw Error('Wait up to 45 seconds');if(!this.link)return {state:'not-open',instructions:'Show the visual chooser with your goal first.'};if(this.pendingAck){await this.request('ack',this.pendingAck);this.pendingAck=null;return {state:'received',...this.receipt};}const end=this.clock()+waitSeconds*1000;do{const value=await this.request('poll',{id:this.link.id,afterSequence:this.sequence});if(value.version!==version||!['waiting','ready'].includes(value.state))throw Error('Unknown website return state');if(value.state==='ready'){const receipt=this.decrypt(value.envelope);try{await this.request('ack',this.pendingAck);this.pendingAck=null;return {state:'received',...receipt};}catch{return {state:'received-acknowledgment-unconfirmed',...receipt};}}if(this.clock()>=end)break;await this.sleep(Math.min(2000,end-this.clock()));}while(this.clock()<=end);return {state:'waiting',chooserUrl:this.link.chooserUrl,pairCode:this.link.pairCode,conversationId:this.receipt?.conversationId??null,lastReceipt:this.receipt??null};}
17
- close(){this.keys=null;this.link=null;this.receipt=null;}
18
- }
1
+ // Delivery keys protect inert presentation data. They never authorize engine calls.
2
+ import {generateKeyPairSync,createPublicKey,diffieHellman,hkdfSync,createDecipheriv,createHash} from 'node:crypto';
3
+ const version='kloudy.website-return/1',hash=s=>createHash('sha256').update(s).digest('hex'),origin='https://kloudy.ai',root=origin+'/api/customer/visual-chooser/return/';
4
+ export const websiteReturnTool={name:'receive_website_selection',description:'Wait for the exact inert selection returned by the signed-in website to this MCP connection. No IDE extension, website cookie, account token, model or grant is needed. Use after showing the chooser link. A waiting result is safe to check again; do not repeat mutations after uncertain outcomes.',inputSchema:{type:'object',additionalProperties:false,properties:{waitSeconds:{type:'integer',minimum:0,maximum:45}}},annotations:{readOnlyHint:true,destructiveHint:false,idempotentHint:true,openWorldHint:false}};
5
+ export class WebsiteSelectionReturn{
6
+ constructor({fetcher=fetch,clock=()=>Date.now(),sleep=ms=>new Promise(r=>setTimeout(r,ms))}={}){Object.assign(this,{fetcher,clock,sleep});this.keys=generateKeyPairSync('x25519');this.publicKey=this.keys.publicKey.export({format:'jwk'}).x;this.sequence=0;this.receipt=null;}
7
+ async request(action,input){const response=await this.fetcher(root+action,{method:'POST',headers:{origin,'content-type':'application/json'},body:JSON.stringify(input),redirect:'error',signal:AbortSignal.timeout(10000)});const text=await response.text();if(text.length>300000)throw Error('Return response exceeds the bounded manifest');const value=JSON.parse(text);if(!response.ok)throw Error(typeof value.error==='string'?value.error:'Website return unavailable');return value;}
8
+ async open(goal){if(this.link)return this.link;if(typeof goal!=='string'||!goal.trim()||goal.length>1000)throw Error('Use a presentation goal up to 1000 characters');const result=await this.request('open',{publicKey:this.publicKey,goal});if(result.version!==version||!/^[a-f0-9]{32}$/.test(result.id)||result.linkIsAuthorization!==false||result.chooserUrl!==origin+'/api/customer/visual-chooser/start#return='+result.id||result.pairCode!==hash(result.id+':'+this.publicKey).slice(0,8).toUpperCase())throw Error('Unexpected website return attachment');this.link=result;return result;}
9
+ decrypt(envelope){
10
+ if(envelope?.version!==version||envelope.id!==this.link?.id||envelope.sequence!==this.sequence+1||!/^[A-Za-z0-9_-]{43}$/.test(envelope.serverPublicKey)||!/^[A-Za-z0-9_-]{16}$/.test(envelope.iv)||!/^[A-Za-z0-9_-]{22}$/.test(envelope.tag)||typeof envelope.ciphertext!=='string'||envelope.ciphertext.length>270000)throw Error('Unexpected return envelope');
11
+ const aad=JSON.stringify([version,this.link.id,hash(this.publicKey),envelope.sequence]),peer=createPublicKey({key:{kty:'OKP',crv:'X25519',x:envelope.serverPublicKey},format:'jwk'}),secret=diffieHellman({privateKey:this.keys.privateKey,publicKey:peer}),key=Buffer.from(hkdfSync('sha256',secret,Buffer.alloc(0),Buffer.from(aad),32)),decipher=createDecipheriv('aes-256-gcm',key,Buffer.from(envelope.iv,'base64url'));decipher.setAAD(Buffer.from(aad));decipher.setAuthTag(Buffer.from(envelope.tag,'base64url'));const payload=JSON.parse(Buffer.concat([decipher.update(Buffer.from(envelope.ciphertext,'base64url')),decipher.final()]).toString()),s=payload.result?.structuredContent,m=s?.selectedManifest;
12
+ if(payload.version!==version||payload.accountProjectVerified!==true||payload.executionAllowed!==false||typeof payload.conversationId!=='string'||typeof payload.receiptId!=='string'||payload.projectId!==m?.projectId||payload.projectId!==s?.projectState?.projectId||payload.revision!==s.revision||m.revision!==s.revision||s.executed!==false||m.executionAllowed!==false||m.authorizesSpending!==false||m.authorizesInstallation!==false)throw Error('Website selection is not an exact inert project manifest');
13
+ if(this.receipt&&(payload.projectId!==this.receipt.projectId||payload.conversationId!==this.receipt.conversationId||s.projectState.originalGoal!==this.receipt.result.structuredContent.projectState.originalGoal))throw Error('Account project or original conversation changed');
14
+ if(typeof payload.ackNonce!=='string'||!/^[A-Za-z0-9_-]{43}$/.test(payload.ackNonce))throw Error('Missing exact delivery acknowledgment');this.pendingAck={id:this.link.id,sequence:envelope.sequence,nonce:payload.ackNonce};delete payload.ackNonce;this.sequence=envelope.sequence;this.receipt=payload;return payload;
15
+ }
16
+ async receive({waitSeconds=45}={}){if(!Number.isInteger(waitSeconds)||waitSeconds<0||waitSeconds>45)throw Error('Wait up to 45 seconds');if(!this.link)return {state:'not-open',instructions:'Show the visual chooser with your goal first.'};if(this.pendingAck){await this.request('ack',this.pendingAck);this.pendingAck=null;return {state:'received',...this.receipt};}const end=this.clock()+waitSeconds*1000;do{const value=await this.request('poll',{id:this.link.id,afterSequence:this.sequence});if(value.version!==version||!['waiting','ready'].includes(value.state))throw Error('Unknown website return state');if(value.state==='ready'){const receipt=this.decrypt(value.envelope);try{await this.request('ack',this.pendingAck);this.pendingAck=null;return {state:'received',...receipt};}catch{return {state:'received-acknowledgment-unconfirmed',...receipt};}}if(this.clock()>=end)break;await this.sleep(Math.min(2000,end-this.clock()));}while(this.clock()<=end);return {state:'waiting',chooserUrl:this.link.chooserUrl,pairCode:this.link.pairCode,conversationId:this.receipt?.conversationId??null,lastReceipt:this.receipt??null};}
17
+ close(){this.keys=null;this.link=null;this.receipt=null;}
18
+ }