kloudy 1.0.1 → 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/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
+ }
package/src/scores.mjs ADDED
@@ -0,0 +1,80 @@
1
+ // Optional recommend score overrides. No model call.
2
+ // GET https://kloudy.ai/api/feedback/scores, cache about 24h, else bundled ranking.
3
+ import {existsSync,mkdirSync,readFileSync,writeFileSync} from 'node:fs';
4
+ import {homedir} from 'node:os';
5
+ import {join} from 'node:path';
6
+
7
+ export const SCORES_URL='https://kloudy.ai/api/feedback/scores';
8
+ export const SCORES_SCHEMA='kloudy.feedback-scores/1';
9
+ export const SCORE_TTL_MS=24*60*60*1000;
10
+ const ACTIONS=new Set(['boost','demote','exclude']);
11
+ const DISABLED=new Set(['','off','offline','0','false']);
12
+
13
+ export function dataDir(){
14
+ return process.env.KLOUDY_DATA_DIR||join(homedir(),'.kloudy');
15
+ }
16
+
17
+ export function scoresCachePath(dir=dataDir()){
18
+ return join(dir,'feedback-scores-cache.json');
19
+ }
20
+
21
+ function botsOf(body){
22
+ if(body.bots&&typeof body.bots==='object'&&!Array.isArray(body.bots))return body.bots;
23
+ const scores=body.scores;
24
+ if(!scores||typeof scores!=='object'||Array.isArray(scores))return null;
25
+ if(scores.bots&&typeof scores.bots==='object'&&!Array.isArray(scores.bots))return scores.bots;
26
+ return scores;
27
+ }
28
+
29
+ export function normalizeScores(body){
30
+ if(!body||typeof body!=='object'||Array.isArray(body))return null;
31
+ const source=botsOf(body);
32
+ if(!source)return null;
33
+ const bots={};
34
+ for(const [id,row] of Object.entries(source)){
35
+ if(typeof id!=='string'||!id)continue;
36
+ const action=typeof row==='string'?row:row&&row.action;
37
+ if(ACTIONS.has(action))bots[id]={action};
38
+ }
39
+ return {schema:typeof body.schema==='string'?body.schema:SCORES_SCHEMA,bots};
40
+ }
41
+
42
+ function scoresUrl(explicit){
43
+ const value=explicit??process.env.KLOUDY_SCORES_URL??SCORES_URL;
44
+ return DISABLED.has(String(value).trim())?null:value;
45
+ }
46
+
47
+ function readCache(file,now){
48
+ try{
49
+ if(!existsSync(file))return null;
50
+ const wrapped=JSON.parse(readFileSync(file,'utf8'));
51
+ const scores=normalizeScores(wrapped.scores??wrapped);
52
+ const fetchedAt=Number(wrapped.fetchedAt);
53
+ if(!scores||!Number.isFinite(fetchedAt))return null;
54
+ return {fetchedAt,scores,fresh:now-fetchedAt>=0&&now-fetchedAt<SCORE_TTL_MS};
55
+ }catch{
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function writeCache(file,fetchedAt,scores){
61
+ mkdirSync(join(file,'..'),{recursive:true});
62
+ writeFileSync(file,JSON.stringify({fetchedAt,scores})+'\n');
63
+ }
64
+
65
+ export async function loadScoreOverrides({fetchImpl=fetch,now=Date.now(),offline=false,dir=dataDir(),url}={}){
66
+ const cached=readCache(scoresCachePath(dir),now);
67
+ if(cached?.fresh)return cached.scores;
68
+ const endpoint=scoresUrl(url);
69
+ if(offline||!endpoint)return null;
70
+ try{
71
+ const response=await fetchImpl(endpoint,{method:'GET',redirect:'error',credentials:'omit',headers:{accept:'application/json'},signal:AbortSignal.timeout(4000)});
72
+ if(!response?.ok)return null;
73
+ const scores=normalizeScores(await response.json());
74
+ if(!scores)return null;
75
+ writeCache(scoresCachePath(dir),now,scores);
76
+ return scores;
77
+ }catch{
78
+ return null;
79
+ }
80
+ }
@@ -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
+ }