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/AGENTS.md +34 -5
- package/README.md +60 -21
- package/bin/entry.mjs +32 -25
- package/bin/kloudy.mjs +4 -1
- package/package.json +2 -2
- package/src/chooser-link.mjs +17 -17
- package/src/conversation.mjs +42 -1
- package/src/feedback.mjs +159 -0
- package/src/hosted.mjs +17 -5
- package/src/httpk.mjs +11 -4
- package/src/mcp.mjs +76 -74
- package/src/recommend.mjs +330 -52
- package/src/remote.mjs +117 -89
- package/src/rooms.mjs +86 -0
- package/src/scores.mjs +80 -0
- package/src/website-return.mjs +18 -18
package/src/feedback.mjs
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Anonymous stack feedback. Opt-in. No goal text, paths, env, or tokens.
|
|
2
|
+
// POST https://kloudy.ai/api/feedback and queue in the Kloudy data directory on failure.
|
|
3
|
+
import {existsSync,mkdirSync,readFileSync,writeFileSync} from 'node:fs';
|
|
4
|
+
import {join} from 'node:path';
|
|
5
|
+
import {dataDir} from './scores.mjs';
|
|
6
|
+
|
|
7
|
+
export const FEEDBACK_URL='https://kloudy.ai/api/feedback';
|
|
8
|
+
export const RESULTS=new Set(['worked','failed','partial']);
|
|
9
|
+
export const REASON_MAX=80;
|
|
10
|
+
const ALLOWED=new Set(['stackId','botId','result','reason','version','client']);
|
|
11
|
+
const DISABLED=new Set(['','off','offline','0','false']);
|
|
12
|
+
|
|
13
|
+
function packageVersion(){
|
|
14
|
+
return JSON.parse(readFileSync(new URL('../package.json',import.meta.url),'utf8')).version;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function feedbackEndpoint(explicit){
|
|
18
|
+
const value=explicit??process.env.KLOUDY_FEEDBACK_URL??FEEDBACK_URL;
|
|
19
|
+
return DISABLED.has(String(value).trim())?null:value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function sanitizeReason(reason){
|
|
23
|
+
if(reason==null||reason==='')return undefined;
|
|
24
|
+
let text=String(reason).replace(/[\u0000-\u001f\u007f]/g,' ').replace(/\s+/g,' ').trim();
|
|
25
|
+
if(!text)return undefined;
|
|
26
|
+
if(/[\\/]|~|\.\.|[A-Za-z]:[\\/]|@[A-Za-z0-9.-]+\.[A-Za-z]{2,}|\b(?:bearer|token|secret|password|api[_-]?key)\b|-----BEGIN/i.test(text))return undefined;
|
|
27
|
+
if(/[A-Fa-f0-9]{24,}/.test(text))return undefined;
|
|
28
|
+
if(/(?:sk|pk|ghp|gho|xox[baprs])-/.test(text))return undefined;
|
|
29
|
+
if(text.length>REASON_MAX)text=text.slice(0,REASON_MAX).trim();
|
|
30
|
+
return text||undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function sanitizeClient(client){
|
|
34
|
+
if(client==null||client==='')return undefined;
|
|
35
|
+
const text=String(client).trim();
|
|
36
|
+
if(!/^[A-Za-z][A-Za-z0-9._-]{0,31}$/.test(text))return undefined;
|
|
37
|
+
return text;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildFeedbackPayload(input={}){
|
|
41
|
+
const stackId=String(input.stackId||'').trim();
|
|
42
|
+
const botId=String(input.botId||'').trim();
|
|
43
|
+
const result=input.result;
|
|
44
|
+
if(!/^[a-f0-9]{64}$/.test(stackId))throw new Error('stack id must be the stackId from recommend --json');
|
|
45
|
+
if(!botId||botId.length>200||/[\s\\]/.test(botId)||botId.includes('..'))throw new Error('bot id is required');
|
|
46
|
+
if(!RESULTS.has(result))throw new Error('result must be worked, failed, or partial');
|
|
47
|
+
const payload={stackId,botId,result,version:input.version||packageVersion()};
|
|
48
|
+
const reason=sanitizeReason(input.reason===input.goal?undefined:input.reason);
|
|
49
|
+
if(reason)payload.reason=reason;
|
|
50
|
+
const client=sanitizeClient(input.client);
|
|
51
|
+
if(client)payload.client=client;
|
|
52
|
+
return payload;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function queuePath(dir=dataDir()){
|
|
56
|
+
return join(dir,'feedback-queue.json');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function safeQueued(item){
|
|
60
|
+
if(!item||typeof item!=='object'||Array.isArray(item))return false;
|
|
61
|
+
const keys=Object.keys(item);
|
|
62
|
+
if(!keys.length||!keys.every(key=>ALLOWED.has(key)))return false;
|
|
63
|
+
if(typeof item.stackId!=='string'||typeof item.botId!=='string'||typeof item.version!=='string')return false;
|
|
64
|
+
if(!RESULTS.has(item.result))return false;
|
|
65
|
+
if(item.reason!=null&&typeof item.reason!=='string')return false;
|
|
66
|
+
if(item.client!=null&&typeof item.client!=='string')return false;
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function readQueue(dir=dataDir()){
|
|
71
|
+
const file=queuePath(dir);
|
|
72
|
+
try{
|
|
73
|
+
if(!existsSync(file))return [];
|
|
74
|
+
const data=JSON.parse(readFileSync(file,'utf8'));
|
|
75
|
+
return Array.isArray(data)?data.filter(safeQueued):[];
|
|
76
|
+
}catch{
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function writeQueue(dir,items){
|
|
82
|
+
mkdirSync(dir,{recursive:true});
|
|
83
|
+
writeFileSync(queuePath(dir),JSON.stringify(items,null,2)+'\n');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function postOne(payload,fetchImpl,url){
|
|
87
|
+
const response=await fetchImpl(url,{
|
|
88
|
+
method:'POST',
|
|
89
|
+
redirect:'error',
|
|
90
|
+
credentials:'omit',
|
|
91
|
+
headers:{'content-type':'application/json',accept:'application/json'},
|
|
92
|
+
body:JSON.stringify(payload),
|
|
93
|
+
signal:AbortSignal.timeout(8000)
|
|
94
|
+
});
|
|
95
|
+
if(!response?.ok)throw new Error('Feedback was not accepted');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function submitFeedback({payload,dir=dataDir(),fetchImpl=fetch,url=feedbackEndpoint()}={}){
|
|
99
|
+
const queued=readQueue(dir);
|
|
100
|
+
if(payload)queued.push(payload);
|
|
101
|
+
if(!queued.length)return {sent:0,queued:0,status:'empty'};
|
|
102
|
+
if(!url){
|
|
103
|
+
writeQueue(dir,queued);
|
|
104
|
+
return {sent:0,queued:queued.length,status:'queued'};
|
|
105
|
+
}
|
|
106
|
+
const pending=[];
|
|
107
|
+
let sent=0;
|
|
108
|
+
let failed=false;
|
|
109
|
+
for(const item of queued){
|
|
110
|
+
if(failed){pending.push(item);continue;}
|
|
111
|
+
try{
|
|
112
|
+
await postOne(item,fetchImpl,url);
|
|
113
|
+
sent++;
|
|
114
|
+
}catch{
|
|
115
|
+
failed=true;
|
|
116
|
+
pending.push(item);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
writeQueue(dir,pending);
|
|
120
|
+
return {sent,queued:pending.length,status:pending.length?(sent?'partial':'queued'):'sent'};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function parseFeedbackArgs(args){
|
|
124
|
+
const o={};
|
|
125
|
+
for(let i=0;i<args.length;i++){
|
|
126
|
+
const arg=args[i];
|
|
127
|
+
if(arg==='--flush'){o.flush=true;continue;}
|
|
128
|
+
if(arg==='--json'){o.json=true;continue;}
|
|
129
|
+
if(!arg.startsWith('--'))throw new Error('Usage: kloudy feedback --stack ID --bot ID --result worked|failed|partial [--reason TEXT] [--client NAME] [--flush]');
|
|
130
|
+
const key=arg.slice(2);
|
|
131
|
+
const value=args[++i];
|
|
132
|
+
if(value==null||String(value).startsWith('--'))throw new Error('Option value required');
|
|
133
|
+
if(!['stack','bot','result','reason','client'].includes(key))throw new Error('Unknown feedback option');
|
|
134
|
+
o[key]=value;
|
|
135
|
+
}
|
|
136
|
+
return o;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function statusLine(result){
|
|
140
|
+
if(result.status==='empty')return 'feedback queue is empty';
|
|
141
|
+
if(result.status==='sent')return `sent ${result.sent} feedback report${result.sent===1?'':'s'}`;
|
|
142
|
+
if(result.status==='partial')return `sent ${result.sent} feedback report${result.sent===1?'':'s'}; ${result.queued} still queued`;
|
|
143
|
+
return `queued ${result.queued} feedback report${result.queued===1?'':'s'}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function feedbackCommand(args,opts={}){
|
|
147
|
+
const out=opts.out||(s=>console.log(s));
|
|
148
|
+
const o=parseFeedbackArgs(args);
|
|
149
|
+
const hasReport=o.stack!=null||o.bot!=null||o.result!=null||o.reason!=null||o.client!=null;
|
|
150
|
+
if(!hasReport&&!o.flush)throw new Error('Usage: kloudy feedback --stack ID --bot ID --result worked|failed|partial [--reason TEXT] [--client NAME] [--flush]');
|
|
151
|
+
if(hasReport&&(!o.stack||!o.bot||!o.result))throw new Error('Feedback requires --stack, --bot, and --result');
|
|
152
|
+
const payload=hasReport?buildFeedbackPayload({stackId:o.stack,botId:o.bot,result:o.result,reason:o.reason,client:o.client,version:opts.version}):undefined;
|
|
153
|
+
const endpoint=opts.url===undefined?feedbackEndpoint():opts.url;
|
|
154
|
+
const result=await submitFeedback({payload,dir:opts.dataDir,fetchImpl:opts.fetch,url:endpoint||null});
|
|
155
|
+
const line=statusLine(result);
|
|
156
|
+
if(o.client!=null&&payload&&!payload.client)out(line+'\nclient omitted');
|
|
157
|
+
else out(line);
|
|
158
|
+
return {...result,payload};
|
|
159
|
+
}
|
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']},
|
|
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:
|
|
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==='
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
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 {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
server.setRequestHandler(
|
|
23
|
-
async
|
|
24
|
-
async function
|
|
25
|
-
server.
|
|
26
|
-
server.setRequestHandler(
|
|
27
|
-
server.setRequestHandler(
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if(request.params.name==='
|
|
38
|
-
if(request.params.name==='
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
else if(op==='
|
|
42
|
-
else if(op==='
|
|
43
|
-
else if(
|
|
44
|
-
else if(
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const result=await
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if(request.params.name
|
|
52
|
-
if(
|
|
53
|
-
if(input.operation==='
|
|
54
|
-
if(input.operation==='
|
|
55
|
-
if(input.operation==='
|
|
56
|
-
if(input.operation==='
|
|
57
|
-
if(input.operation==='
|
|
58
|
-
if(input.operation==='
|
|
59
|
-
if(input.operation==='
|
|
60
|
-
if(input.operation==='
|
|
61
|
-
if(input.operation==='
|
|
62
|
-
if(input.operation==='
|
|
63
|
-
if(input.operation==='
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
|
|
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;}
|