kloudy 0.4.4 → 0.4.6

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/src/public.mjs CHANGED
@@ -1,41 +1,41 @@
1
- import {validateSDF} from './sdf.mjs';
2
- import {KloudyError} from './client.mjs';
3
- const fail=(code,message)=>{throw new KloudyError(code,message);};
4
- async function request(path,options,fetcher){
5
- let response;try{response=await fetcher('https://kloudy.ai'+path,{...options,redirect:'error',signal:AbortSignal.timeout(25000)});}catch{fail('public_unavailable','Kloudy public reading is unavailable. No account action was submitted.');}
6
- if(!response.ok)fail(response.status===429?'rate_limited':'public_unavailable','The public resource could not be read. Check the URL or try again later.');
7
- const chunks=[];let size=0;for await(const chunk of response.body){size+=chunk.length;if(size>2097152)fail('invalid_response','Public response exceeded its size limit.');chunks.push(chunk);}
8
- try{return JSON.parse(Buffer.concat(chunks).toString());}catch{fail('invalid_response','Expected a JSON public response.');}
9
- }
10
- export async function readPublicPage(address,{fetcher=fetch}={}){
11
- let url;try{url=new URL(address);}catch{}
12
- if(!url||!['https:','http:'].includes(url.protocol)||url.username||url.password||url.href.length>2048)fail('invalid_url','Use a public HTTP(S) URL without embedded credentials.');
13
- const result=await request('/api/read',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({url:url.href})},fetcher);
14
- let source;try{source=new URL(result.url);}catch{}
15
- if(!source||!['http:','https:'].includes(source.protocol)||source.username||source.password||typeof result.body!=='string'||typeof result.type!=='string')fail('invalid_response','Expected source-attributed public page content.');
16
- return {url:source.href,type:result.type,body:result.body,bytes:result.bytes??Buffer.byteLength(result.body)};
17
- }
18
- export async function readCard(path='/world',{fetcher=fetch}={}){
19
- if(typeof path!=='string'||!/^\/(?:[a-zA-Z0-9_-]|%20)*$/.test(path))fail('invalid_path','Use a Kloudy page path such as /world.');
20
- const card=await request('/cards/'+(path==='/'?'home':path.slice(1))+'.json',{headers:{accept:'application/json'}},fetcher);
21
- if(card?.interaction_mode!=='informational_only'||!Array.isArray(card.capabilities)||card.capabilities.length||typeof card.description!=='string'||card.description.length>5000||typeof card.name!=='string'||card.website!=='https://kloudy.ai'+path)fail('invalid_response','Expected an informational Kloudy card without execution permissions.');
22
- return card;
23
- }
24
- export async function convertPage(address,{fetcher=fetch,resolution="compact"}={}){
25
- let url;try{url=new URL(address);}catch{}
26
- if(!url||!['https:','http:'].includes(url.protocol)||url.username||url.password||url.href.length>2048)fail('invalid_url','Use a public HTTP(S) URL without embedded credentials.');
27
- if(!['compact','standard'].includes(resolution))fail('invalid_resolution','Choose compact or standard conversion.');
28
- const card=await request('/api/convert/sdf',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({url:url.href,resolution})},fetcher);
29
- if(!validateSDF(card).valid)fail('invalid_response','Expected a valid bounded SDF webpage card.');
30
- return card;
31
- }
32
- export async function convertPages(addresses,options={}){
33
- if(!Array.isArray(addresses)||!addresses.length||addresses.length>8||addresses.some(a=>typeof a!=='string'||a.length>2048))fail('invalid_input','Convert 1–8 public addresses at a time.');
34
- const cards=[],reports=[],seen=new Map();
35
- for(const address of addresses){let canonical;try{const url=new URL(address);url.hash='';canonical=url.username||url.password?'invalid-address':url.href;}catch{canonical=address;}
36
- if(seen.has(canonical)){reports.push({...seen.get(canonical),duplicate:true});continue;}
37
- let report;try{const card=await convertPage(address,options);cards.push(card);report={source:canonical,status:'converted',card_id:card.id};}catch(error){report={source:canonical,status:'unreadable',reason:error.code||'conversion_failed',message:error.code?error.message:'Conversion could not finish.'};}
38
- reports.push(report);seen.set(canonical,report);
39
- }
40
- return {schema:'kloudy.sdf.card-list/1',cards,reports,ranking:'none',model_calls:0};
41
- }
1
+ import {validateSDF} from './sdf.mjs';
2
+ import {KloudyError} from './client.mjs';
3
+ const fail=(code,message)=>{throw new KloudyError(code,message);};
4
+ async function request(path,options,fetcher){
5
+ let response;try{response=await fetcher('https://kloudy.ai'+path,{...options,redirect:'error',signal:AbortSignal.timeout(25000)});}catch{fail('public_unavailable','Kloudy public reading is unavailable. No account action was submitted.');}
6
+ if(!response.ok)fail(response.status===429?'rate_limited':'public_unavailable','The public resource could not be read. Check the URL or try again later.');
7
+ const chunks=[];let size=0;for await(const chunk of response.body){size+=chunk.length;if(size>2097152)fail('invalid_response','Public response exceeded its size limit.');chunks.push(chunk);}
8
+ try{return JSON.parse(Buffer.concat(chunks).toString());}catch{fail('invalid_response','Expected a JSON public response.');}
9
+ }
10
+ export async function readPublicPage(address,{fetcher=fetch}={}){
11
+ let url;try{url=new URL(address);}catch{}
12
+ if(!url||!['https:','http:'].includes(url.protocol)||url.username||url.password||url.href.length>2048)fail('invalid_url','Use a public HTTP(S) URL without embedded credentials.');
13
+ const result=await request('/api/read',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({url:url.href})},fetcher);
14
+ let source;try{source=new URL(result.url);}catch{}
15
+ if(!source||!['http:','https:'].includes(source.protocol)||source.username||source.password||typeof result.body!=='string'||typeof result.type!=='string')fail('invalid_response','Expected source-attributed public page content.');
16
+ return {url:source.href,type:result.type,body:result.body,bytes:result.bytes??Buffer.byteLength(result.body)};
17
+ }
18
+ export async function readCard(path='/world',{fetcher=fetch}={}){
19
+ if(typeof path!=='string'||!/^\/(?:[a-zA-Z0-9_-]|%20)*$/.test(path))fail('invalid_path','Use a Kloudy page path such as /world.');
20
+ const card=await request('/cards/'+(path==='/'?'home':path.slice(1))+'.json',{headers:{accept:'application/json'}},fetcher);
21
+ if(card?.interaction_mode!=='informational_only'||!Array.isArray(card.capabilities)||card.capabilities.length||typeof card.description!=='string'||card.description.length>5000||typeof card.name!=='string'||card.website!=='https://kloudy.ai'+path)fail('invalid_response','Expected an informational Kloudy card without execution permissions.');
22
+ return card;
23
+ }
24
+ export async function convertPage(address,{fetcher=fetch,resolution="compact"}={}){
25
+ let url;try{url=new URL(address);}catch{}
26
+ if(!url||!['https:','http:'].includes(url.protocol)||url.username||url.password||url.href.length>2048)fail('invalid_url','Use a public HTTP(S) URL without embedded credentials.');
27
+ if(!['compact','standard'].includes(resolution))fail('invalid_resolution','Choose compact or standard conversion.');
28
+ const card=await request('/api/convert/sdf',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({url:url.href,resolution})},fetcher);
29
+ if(!validateSDF(card).valid)fail('invalid_response','Expected a valid bounded SDF webpage card.');
30
+ return card;
31
+ }
32
+ export async function convertPages(addresses,options={}){
33
+ if(!Array.isArray(addresses)||!addresses.length||addresses.length>8||addresses.some(a=>typeof a!=='string'||a.length>2048))fail('invalid_input','Convert 1–8 public addresses at a time.');
34
+ const cards=[],reports=[],seen=new Map();
35
+ for(const address of addresses){let canonical;try{const url=new URL(address);url.hash='';canonical=url.username||url.password?'invalid-address':url.href;}catch{canonical=address;}
36
+ if(seen.has(canonical)){reports.push({...seen.get(canonical),duplicate:true});continue;}
37
+ let report;try{const card=await convertPage(address,options);cards.push(card);report={source:canonical,status:'converted',card_id:card.id};}catch(error){report={source:canonical,status:'unreadable',reason:error.code||'conversion_failed',message:error.code?error.message:'Conversion could not finish.'};}
38
+ reports.push(report);seen.set(canonical,report);
39
+ }
40
+ return {schema:'kloudy.sdf.card-list/1',cards,reports,ranking:'none',model_calls:0};
41
+ }
package/src/remote.mjs CHANGED
@@ -4,12 +4,21 @@ import {Client} from '@modelcontextprotocol/sdk/client/index.js';
4
4
  import {StreamableHTTPClientTransport} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
5
5
  import {Server} from '@modelcontextprotocol/sdk/server/index.js';
6
6
  import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js';
7
- import {ListToolsRequestSchema,CallToolRequestSchema} from '@modelcontextprotocol/sdk/types.js';
7
+ import {ListToolsRequestSchema,CallToolRequestSchema,ListResourcesRequestSchema,ReadResourceRequestSchema,ListResourceTemplatesRequestSchema,ToolListChangedNotificationSchema,ResourceListChangedNotificationSchema} from '@modelcontextprotocol/sdk/types.js';
8
8
  import {REMOTE} from './editor-install.mjs';
9
- export async function connectRemote({key=process.env.KLOUDY_MCP_TOKEN,fetcher=fetch}={}){
9
+ import {autoupdateEnabled} from './autoupdate.mjs';
10
+ const transports=new WeakMap();
11
+ // This proxy can relay Apps rendering, but cannot service sampling, roots or elicitation.
12
+ export function forwardedCapabilities(value={}){
13
+ const ui=value.experimental?.['io.modelcontextprotocol/ui'];
14
+ const mimeTypes=Array.isArray(ui?.mimeTypes)?ui.mimeTypes.filter(x=>x==='text/html;profile=mcp-app'):[];
15
+ return mimeTypes.length?{experimental:{'io.modelcontextprotocol/ui':{mimeTypes:[...new Set(mimeTypes)]}}}:{};
16
+ }
17
+ export async function connectRemote({key=process.env.KLOUDY_MCP_TOKEN,fetcher=fetch,capabilities={}}={}){
10
18
  if(key!==undefined&&!/^\S{20,512}$/.test(key))throw Error('Invalid key');
11
- const client=new Client({name:'kloudy',version:packageVersion},{capabilities:{}});
19
+ const client=new Client({name:'kloudy',version:packageVersion},{capabilities:forwardedCapabilities(capabilities)});
12
20
  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}});
21
+ transports.set(client,transport);
13
22
  try{await client.connect(transport,{timeout:25000});return client;}catch(e){await client.close().catch(()=>{});throw e;}
14
23
  }
15
24
  export async function askRemote(question,options){
@@ -17,11 +26,41 @@ export async function askRemote(question,options){
17
26
  const client=await connectRemote(options);
18
27
  try{return await client.callTool({name:'kloudy',arguments:{operation:'ask',goal:question}},undefined,{timeout:25000});}finally{await client.close();}
19
28
  }
20
- export async function serveRemote(options){
21
- const remote=await connectRemote(options);
22
- const server=new Server({name:'kloudy',version:packageVersion},{capabilities:{tools:{}},instructions:'Use Kloudy when mentioned, or to discover MCP tools and SDKs. Public discovery is free; account actions need an existing authorized key.'});
23
- server.setRequestHandler(ListToolsRequestSchema,req=>remote.listTools(req.params));
24
- server.setRequestHandler(CallToolRequestSchema,req=>remote.callTool(req.params,undefined,{timeout:25000}));
25
- server.onclose=()=>{void remote.close();};
26
- await server.connect(new StdioServerTransport());return server;
29
+ export async function serveRemote(options={}){
30
+ const connect=options.connect??connectRemote,enabled=autoupdateEnabled(options.autoupdate??process.env.KLOUDY_MCP_AUTOUPDATE);
31
+ let remote=await connect({...options,capabilities:{}}),ready=Promise.resolve(),closed=false,reconnecting;
32
+ const upstream=remote.getServerCapabilities(),capabilities={};
33
+ if(upstream.tools)capabilities.tools=enabled&&upstream.tools.listChanged?{listChanged:true}:{};
34
+ if(upstream.resources)capabilities.resources=enabled&&upstream.resources.listChanged?{listChanged:true}:{};
35
+ 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.'});
36
+ function forward(client){
37
+ if(capabilities.tools?.listChanged)client.setNotificationHandler(ToolListChangedNotificationSchema,()=>server.sendToolListChanged());
38
+ if(capabilities.resources?.listChanged)client.setNotificationHandler(ResourceListChangedNotificationSchema,()=>server.sendResourceListChanged());
39
+ }
40
+ forward(remote);
41
+ server.oninitialized=()=>{
42
+ const actual=forwardedCapabilities(server.getClientCapabilities());
43
+ if(!Object.keys(actual).length)return;
44
+ 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();})();
45
+ // Requests still receive negotiation errors; avoid an unhandled rejection before the first request.
46
+ void ready.catch(()=>{});
47
+ };
48
+ const invoke=async(method,params)=>{
49
+ await ready;const current=remote,run=client=>method==='callTool'?client.callTool(params,undefined,{timeout:25000}):client[method](params);
50
+ try{return await run(current);}catch(error){
51
+ // Preserve 0.4.5's opt-in recovery: only an established HTTP session's explicit 404 permits one retry.
52
+ if(!enabled||error.code!==404||!transports.get(current)?.sessionId)throw error;
53
+ 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;}
54
+ return run(remote);
55
+ }
56
+ };
57
+ if(capabilities.tools){server.setRequestHandler(ListToolsRequestSchema,req=>invoke('listTools',req.params));server.setRequestHandler(CallToolRequestSchema,req=>invoke('callTool',req.params));}
58
+ // Resource delivery is independent of notification/autoupdate opt-in.
59
+ if(capabilities.resources){
60
+ server.setRequestHandler(ListResourcesRequestSchema,req=>invoke('listResources',req.params));
61
+ server.setRequestHandler(ReadResourceRequestSchema,req=>invoke('readResource',req.params));
62
+ server.setRequestHandler(ListResourceTemplatesRequestSchema,req=>invoke('listResourceTemplates',req.params));
63
+ }
64
+ server.onclose=()=>{closed=true;void ready.catch(()=>{}).then(()=>remote.close());};
65
+ await server.connect(options.transport??new StdioServerTransport());return server;
27
66
  }
@@ -42,6 +42,6 @@ export class SampleQueue{
42
42
  const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
43
43
  export function samplePreferencesMarkup(flow,{surface='site',notice=''}={}){
44
44
  const q=preferenceQuestion(flow.step,surface),complete=!q;
45
- return `<section class="sample-card" aria-label="${SAMPLE_TITLE}"><header><span class="sample-orb" aria-hidden="true"></span><span>${SAMPLE_TITLE}</span><small>${complete?'Ready':(flow.step+1)+' / 3'}</small></header><h1 tabindex="-1">${complete?'Your way to build.':esc(q.question)}</h1>${complete?'':`<p>${esc(q.intro)}</p>`}${complete?`<dl>${Object.entries(preferenceChoices).map(([key,options])=>`<dt>${esc({tools:'Tools',target:'Where',delivery:'Updates'}[key])}</dt><dd>${esc(options.find(([id])=>id===flow.value[key])?.[1]||'Ask when needed')}</dd>`).join('')}</dl><button class="sample-primary" data-sample-save>Save preferences</button>`:`<div class="sample-options">${preferenceChoices[q.key].map(([id,label])=>`<button data-sample-choice="${id}" aria-pressed="${flow.value[q.key]===id}">${esc(label)}</button>`).join('')}</div>`}<p class="sample-note">${q?.key==='tools'?'Open source describes the tools; hosting or compute may still cost money.':q?.key==='target'?'Bot Wide Web sites need hosting: your computer, your cloud, or a paid host.':'New costs always need your approval. Ask which tools are connected anytime.'}</p><details><summary>How this works</summary><p>Quietly means fewer updates, not permission to spend. Free tiers ending always prompt a choice. These preferences do not install tools or authorize a connection.</p></details><footer>${q?.key==='delivery'?'<button data-sample-choice="'+esc(flow.value.delivery)+'">Continue</button>':''}${flow.step?'<button data-sample-back>Back</button>':''}<button data-sample-later>Not now</button></footer><p role="status" class="sample-status">${esc(notice)}</p></section>`;
45
+ return `<section class="sample-card" aria-label="${SAMPLE_TITLE}"><header><span class="sample-orb" aria-hidden="true"></span><span>${SAMPLE_TITLE}</span><small>${complete?'Ready':(flow.step+1)+' / 3'}</small></header><h1 tabindex="-1">${complete?'Your way to build.':esc(q.question)}</h1>${complete?'':`<p>${esc(q.intro)}</p>`}${complete?`<dl>${Object.entries(preferenceChoices).map(([key,options])=>`<dt>${esc({tools:'Tools',target:'Where',delivery:'Updates'}[key])}</dt><dd>${esc(options.find(([id])=>id===flow.value[key])?.[1]||'Ask when needed')}</dd>`).join('')}</dl><button class="sample-primary" data-sample-save>Save preferences</button>`:`<div class="sample-options">${preferenceChoices[q.key].map(([id,label])=>`<button data-sample-choice="${id}" aria-pressed="${flow.value[q.key]===id}">${esc(label)}</button>`).join('')}</div>`}<p class="sample-note">${q?.key==='tools'?'Open source describes the tools; hosting or compute may still cost money.':q?.key==='target'?'Bot Wide Web sites need hosting: your computer, your cloud, or a paid host.':'New costs always need your approval. Ask which tools are connected anytime.'}</p><details><summary>How this works</summary><p>Quietly means fewer updates, not permission to spend. Free tiers ending always prompt a choice. These preferences do not install tools or authorize a connection.</p></details><footer>${flow.step?'<button data-sample-back>Back</button>':''}<button data-sample-later>Not now</button></footer><p role="status" class="sample-status">${esc(notice)}</p></section>`;
46
46
  }
47
47
  export function conversionCardMarkup(){return `<section class="sample-card"><header>${SAMPLE_TITLE}</header><h1>Let bots reach your site.</h1><p>Publish a BWW card so compatible assistants can fetch the part a user needs.</p><a class="sample-primary" href="https://kloudy.ai/hosting">Choose hosting</a><details><summary>Benefits and costs</summary><p>A converted site can offer structured fragments instead of a full browser visit. Actual compute, water and cost savings depend on measured usage; no fixed savings are promised.</p><p>Bring your PC or cloud, or use a paid host. If your compute goes offline, your site becomes unavailable. Concierge and ad bots are optional paid services.</p></details></section>`;}