kloudy 0.4.3 → 0.4.5

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
@@ -1,13 +1,18 @@
1
+ import {readFileSync} from 'node:fs';
2
+ const packageVersion=JSON.parse(readFileSync(new URL('../package.json',import.meta.url),'utf8')).version;
1
3
  import {Client} from '@modelcontextprotocol/sdk/client/index.js';
2
4
  import {StreamableHTTPClientTransport} from '@modelcontextprotocol/sdk/client/streamableHttp.js';
3
5
  import {Server} from '@modelcontextprotocol/sdk/server/index.js';
4
6
  import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js';
5
- import {ListToolsRequestSchema,CallToolRequestSchema} from '@modelcontextprotocol/sdk/types.js';
7
+ import {ListToolsRequestSchema,CallToolRequestSchema,ListResourcesRequestSchema,ReadResourceRequestSchema,ToolListChangedNotificationSchema,ResourceListChangedNotificationSchema} from '@modelcontextprotocol/sdk/types.js';
6
8
  import {REMOTE} from './editor-install.mjs';
9
+ import {autoupdateEnabled} from './autoupdate.mjs';
10
+ const transports=new WeakMap();
7
11
  export async function connectRemote({key=process.env.KLOUDY_MCP_TOKEN,fetcher=fetch}={}){
8
12
  if(key!==undefined&&!/^\S{20,512}$/.test(key))throw Error('Invalid key');
9
- const client=new Client({name:'kloudy',version:'0.4.1'},{capabilities:{}});
13
+ const client=new Client({name:'kloudy',version:packageVersion},{capabilities:{}});
10
14
  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}});
15
+ transports.set(client,transport);
11
16
  try{await client.connect(transport,{timeout:25000});return client;}catch(e){await client.close().catch(()=>{});throw e;}
12
17
  }
13
18
  export async function askRemote(question,options){
@@ -15,11 +20,40 @@ export async function askRemote(question,options){
15
20
  const client=await connectRemote(options);
16
21
  try{return await client.callTool({name:'kloudy',arguments:{operation:'ask',goal:question}},undefined,{timeout:25000});}finally{await client.close();}
17
22
  }
18
- export async function serveRemote(options){
19
- const remote=await connectRemote(options);
20
- const server=new Server({name:'kloudy',version:'0.4.1'},{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.'});
21
- server.setRequestHandler(ListToolsRequestSchema,req=>remote.listTools(req.params));
22
- server.setRequestHandler(CallToolRequestSchema,req=>remote.callTool(req.params,undefined,{timeout:25000}));
23
+ export async function serveRemote(options={}){
24
+ const enabled=autoupdateEnabled(options.autoupdate??process.env.KLOUDY_MCP_AUTOUPDATE);
25
+ let remote=await connectRemote(options),reconnecting;
26
+ const server=new Server({name:'kloudy',version:packageVersion},{capabilities:enabled?{tools:{listChanged:true},resources:{listChanged:true}}:{tools:{}},instructions:'Use Kloudy when mentioned, or to discover MCP tools and SDKs. Public discovery is free; account actions need an existing authorized key.'});
27
+ function forward(client){
28
+ if(!enabled)return;
29
+ client.setNotificationHandler(ToolListChangedNotificationSchema,()=>server.sendToolListChanged());
30
+ client.setNotificationHandler(ResourceListChangedNotificationSchema,()=>server.sendResourceListChanged());
31
+ }
32
+ forward(remote);
33
+ async function invoke(method,params){
34
+ const current=remote;
35
+ const run=client=>method==='callTool'?client.callTool(params,undefined,{timeout:25000}):client[method](params);
36
+ try{return await run(current);}catch(error){
37
+ // Only an explicit HTTP 404 on an established session permits one retry.
38
+ // Ambiguous network/timeout errors and session-less 404s are never retried.
39
+ if(!enabled||error.code!==404||!transports.get(current)?.sessionId)throw error;
40
+ if(remote===current){
41
+ reconnecting??=(async()=>{
42
+ const next=await connectRemote(options);forward(next);remote=next;
43
+ await current.close().catch(()=>{});
44
+ await server.sendToolListChanged();await server.sendResourceListChanged();
45
+ })().finally(()=>{reconnecting=undefined;});
46
+ await reconnecting;
47
+ }
48
+ return run(remote);
49
+ }
50
+ }
51
+ server.setRequestHandler(ListToolsRequestSchema,req=>invoke('listTools',req.params));
52
+ server.setRequestHandler(CallToolRequestSchema,req=>invoke('callTool',req.params));
53
+ if(enabled){
54
+ server.setRequestHandler(ListResourcesRequestSchema,req=>invoke('listResources',req.params));
55
+ server.setRequestHandler(ReadResourceRequestSchema,req=>invoke('readResource',req.params));
56
+ }
23
57
  server.onclose=()=>{void remote.close();};
24
- await server.connect(new StdioServerTransport());return server;
58
+ await server.connect(options.transport??new StdioServerTransport());return server;
25
59
  }
@@ -2,9 +2,9 @@
2
2
  export const SAMPLE_VERSION='kloudy.sample/1';
3
3
  export const SAMPLE_TITLE='Kloudy Sample card';
4
4
  export const preferenceChoices={
5
- tools:[['open_source','Open source'],['paid','Paid · convenience'],['hybrid','Mix of both'],['free_then_paid','Free tier, then ask']],
6
- target:[['local','Local only'],['www','World Wide Web'],['bww','Bot Wide Web'],['mixed','A mix']],
7
- delivery:[['silent','Silent · recommended'],['paid','Cards for paid tools'],['all','Cards for every tool']]
5
+ tools:[['open_source','Open source'],['paid','Paid tools'],['hybrid','Mix of both'],['free_then_paid','Free tier, then ask']],
6
+ target:[['local','This device'],['www','World Wide Web'],['bww','Bot Wide Web'],['mixed','A mix']],
7
+ delivery:[['silent','Quietly'],['paid','Paid tools only'],['all','Every tool']]
8
8
  };
9
9
  export const preferenceDefaults=()=>({tools:null,target:null,delivery:'silent'});
10
10
  export function validPreferences(value){return !!value&&typeof value==='object'&&!Array.isArray(value)&&Object.keys(value).length===3&&Object.entries(preferenceChoices).every(([key,options])=>(key!=='delivery'&&value[key]===null)||options.some(([id])=>id===value[key]));}
@@ -12,8 +12,8 @@ export function preferenceQuestion(step,surface='site'){
12
12
  const external=['ide','chatbot','terminal'].includes(surface);
13
13
  return [
14
14
  {key:'tools',question:'Which tools do you prefer?',intro:external?'Kloudy gives your assistant tools to build your projects.':'Kloudy uses tools to build your projects.'},
15
- {key:'target',question:'Where will your projects run?',intro:'Choose the places you want to serve.'},
16
- {key:'delivery',question:'How much should Kloudy show?',intro:'Work quietly, or show the tools as they are offered.'}
15
+ {key:'target',question:'Where will your projects run?',intro:'Choose where people will use your projects.'},
16
+ {key:'delivery',question:'How much should Kloudy show?',intro:'Quietly is the default. You can ask for updates instead.'}
17
17
  ][step]||null;
18
18
  }
19
19
  export class SamplePreferences{
@@ -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'?'BWW sites need hosting: your compute or a paid host.':'New costs always need your approval. Ask which tools are connected anytime.'}</p><details><summary>How this works</summary><p>Silent 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>`;
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>`;}