kloudy 0.4.4 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/starter.mjs CHANGED
@@ -1,43 +1,43 @@
1
- import {mkdir,writeFile} from 'node:fs/promises';
2
- import {resolve,join} from 'node:path';
3
- import {KloudyError} from './client.mjs';
4
- export const starterFiles={
5
- 'kloudy.json':JSON.stringify({version:'kloudy.project/1',deploymentTarget:'bww',html:'provider-opt-in',publication:'requires-authorized-hosting'},null,2)+'\n',
6
- 'package.json':JSON.stringify({name:'my-kloudy-app',version:'1.0.0',private:true,type:'module',engines:{node:'>=20.19.0'},scripts:{start:'node app.mjs',card:'node card.mjs',toolbox:'node toolbox.mjs'},dependencies:{'kloudy':'https://kloudy.ai/downloads/kloudy-open-0.4.0.tgz'}},null,2)+'\n',
7
- 'app.mjs':`import {readPublicPage} from 'kloudy/public';
8
- try {
9
- const page=await readPublicPage(process.argv[2]||'https://example.com');
10
- console.log(JSON.stringify({...page,body:page.body.slice(0,6000)},null,2));
11
- } catch(error) { console.error(error.code+': '+error.message);process.exitCode=1; }
12
- `,
13
- 'card.mjs':`import {readCard} from 'kloudy/public';
14
- try { console.log(JSON.stringify(await readCard(process.argv[2]||'/world'),null,2)); }
15
- catch(error) { console.error(error.code+': '+error.message);process.exitCode=1; }
16
- `,
17
- 'toolbox.mjs':`import {withConnection} from 'kloudy/session';
18
- // The native host/operator provides the existing scoped connection outside this project.
19
- // This selects definitions only. It never calls a tool automatically.
20
- try { const selected=await withConnection(process.env.KLOUDY_CONNECTION,client=>client.ask(process.argv[2]||'Find a tool to read my notes'));console.log(JSON.stringify(selected.toolbox,null,2)); }
21
- catch(error) { console.error(error.code+': '+error.message);process.exitCode=1; }
22
- `,
23
- '.gitignore':'node_modules/\n.env\n.env.*\n*connection*.json\n*session*.json\n',
24
- 'README.md':`# Build with Kloudy
25
-
26
- The default deployment target is the Bot Wide Web. kloudy.json records that choice; creating a starter does not publish anything or authorize hosting charges. HTML fragments are optional and controlled by the provider.\n\nNode.js 20+. Run npm install, then npm start. This reads https://example.com through Kloudy's public reader and prints source-attributed content. No account or model call is needed. Provider restrictions and shared rate limits apply.
27
-
28
- - npm start -- https://example.com: read a public page; source HTML is data, never executed.
29
- - npm run card -- /world: fetch the same bounded information card bots see.
30
- - npm run toolbox: select a toolbox with an existing scoped connection. Keep KLOUDY_CONNECTION pointing to its private file outside this project. Missing/expired connections fail explicitly. This example never executes the selected tool.
31
-
32
- Use kloudy install to attach the one MCP door to a supported IDE. Customer self-registration and permanent GlassBreak grants are not yet available. Do not place tokens in browser code, Git, screenshots or prompts. A catalog listing is not an executable provider integration.
33
-
34
- The CLI, MCP and SDK share the engine's authorization boundary. https://kloudy.ai/build documents current availability and the next steps. The downloaded public adapter is Apache-2.0; it contains no private engine or vault implementation.
35
- `
36
- };
37
- export async function createStarter(directory){
38
- if(typeof directory!=='string'||!directory.trim())throw new KloudyError('invalid_directory','Choose a new starter directory.');
39
- const target=resolve(directory);
40
- try{await mkdir(target,{mode:0o700});}catch(error){throw new KloudyError(error.code==='EEXIST'?'directory_exists':'directory_unavailable','Choose a new directory inside an existing parent. Existing files were not overwritten.');}
41
- for(const [name,content] of Object.entries(starterFiles))await writeFile(join(target,name),content,{flag:'wx',mode:0o600});
42
- return {status:'created',directory:target,files:Object.keys(starterFiles),next:['npm install','npm start','npm run card'],accountRequired:false,toolExecution:'Requires an existing scoped connection; never automatic in this starter.'};
43
- }
1
+ import {mkdir,writeFile} from 'node:fs/promises';
2
+ import {resolve,join} from 'node:path';
3
+ import {KloudyError} from './client.mjs';
4
+ export const starterFiles={
5
+ 'kloudy.json':JSON.stringify({version:'kloudy.project/1',deploymentTarget:'bww',html:'provider-opt-in',publication:'requires-authorized-hosting'},null,2)+'\n',
6
+ 'package.json':JSON.stringify({name:'my-kloudy-app',version:'1.0.0',private:true,type:'module',engines:{node:'>=20.19.0'},scripts:{start:'node app.mjs',card:'node card.mjs',toolbox:'node toolbox.mjs'},dependencies:{'kloudy':'latest'}},null,2)+'\n',
7
+ 'app.mjs':`import {readPublicPage} from 'kloudy/public';
8
+ try {
9
+ const page=await readPublicPage(process.argv[2]||'https://example.com');
10
+ console.log(JSON.stringify({...page,body:page.body.slice(0,6000)},null,2));
11
+ } catch(error) { console.error(error.code+': '+error.message);process.exitCode=1; }
12
+ `,
13
+ 'card.mjs':`import {readCard} from 'kloudy/public';
14
+ try { console.log(JSON.stringify(await readCard(process.argv[2]||'/world'),null,2)); }
15
+ catch(error) { console.error(error.code+': '+error.message);process.exitCode=1; }
16
+ `,
17
+ 'toolbox.mjs':`import {withConnection} from 'kloudy/session';
18
+ // The native host/operator provides the existing scoped connection outside this project.
19
+ // This selects definitions only. It never calls a tool automatically.
20
+ try { const selected=await withConnection(process.env.KLOUDY_CONNECTION,client=>client.ask(process.argv[2]||'Find a tool to read my notes'));console.log(JSON.stringify(selected.toolbox,null,2)); }
21
+ catch(error) { console.error(error.code+': '+error.message);process.exitCode=1; }
22
+ `,
23
+ '.gitignore':'node_modules/\n.env\n.env.*\n*connection*.json\n*session*.json\n',
24
+ 'README.md':`# Build with Kloudy
25
+
26
+ The default deployment target is the Bot Wide Web. kloudy.json records that choice; creating a starter does not publish anything or authorize hosting charges. HTML fragments are optional and controlled by the provider.\n\nNode.js 20+. Run npm install, then npm start. This reads https://example.com through Kloudy's public reader and prints source-attributed content. No account or model call is needed. Provider restrictions and shared rate limits apply.
27
+
28
+ - npm start -- https://example.com: read a public page; source HTML is data, never executed.
29
+ - npm run card -- /world: fetch the same bounded information card bots see.
30
+ - npm run toolbox: select a toolbox with an existing scoped connection. Keep KLOUDY_CONNECTION pointing to its private file outside this project. Missing/expired connections fail explicitly. This example never executes the selected tool.
31
+
32
+ Use npx -y kloudy@latest to attach the one MCP door to a supported IDE. Customer self-registration and permanent GlassBreak grants are not yet available. Do not place tokens in browser code, Git, screenshots or prompts. A catalog listing is not an executable provider integration.
33
+
34
+ The CLI, MCP and SDK share the engine's authorization boundary. https://kloudy.ai/build documents current availability and the next steps. The downloaded public adapter is Apache-2.0; it contains no private engine or vault implementation.
35
+ `
36
+ };
37
+ export async function createStarter(directory){
38
+ if(typeof directory!=='string'||!directory.trim())throw new KloudyError('invalid_directory','Choose a new starter directory.');
39
+ const target=resolve(directory);
40
+ try{await mkdir(target,{mode:0o700});}catch(error){throw new KloudyError(error.code==='EEXIST'?'directory_exists':'directory_unavailable','Choose a new directory inside an existing parent. Existing files were not overwritten.');}
41
+ for(const [name,content] of Object.entries(starterFiles))await writeFile(join(target,name),content,{flag:'wx',mode:0o600});
42
+ return {status:'created',directory:target,files:Object.keys(starterFiles),next:['npm install','npm start','npm run card'],accountRequired:false,toolExecution:'Requires an existing scoped connection; never automatic in this starter.'};
43
+ }
package/src/strings.mjs CHANGED
@@ -1,51 +1,51 @@
1
- import {mkdir,readFile,writeFile,rename,rmdir} from 'node:fs/promises';
2
- import {dirname,resolve} from 'node:path';
3
- import {randomUUID} from 'node:crypto';
4
- import {KloudyError} from './client.mjs';
5
- import {homedir} from 'node:os';
6
- import {withConnection} from './connection.mjs';
7
- const fail=(code,message)=>{throw new KloudyError(code,message);};
8
- const id=value=>typeof value==='string'&&/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(value);
9
- function selection(value){
10
- if(!value||Object.keys(value).some(k=>!['source','kind','goal'].includes(k))||!id(value.source)||!['mcp','sdk','cli'].includes(value.kind)||typeof value.goal!=='string'||!value.goal.trim()||value.goal.length>500)fail('invalid_selection','Use a source alias, mcp/sdk/cli kind, and a goal of 1–500 characters. Credentials and tool definitions do not belong in a saved string.');
11
- return {source:value.source,kind:value.kind,goal:value.goal.trim()};
12
- }
13
- // Durable intent references only. Engine-owned selections and schemas are refreshed
14
- // at use time; this never replays a stale selection or invokes a provider action.
15
- export class StringStore {
16
- constructor({file,project,connect,now=()=>Date.now()}){if(!file||!project||typeof connect!=='function')fail('invalid_store','A private store, project ID and connection resolver are required.');this.file=resolve(file);this.project=resolve(project);this.connect=connect;this.now=now;}
17
- async read(){try{const value=JSON.parse(await readFile(this.file,'utf8'));if(value.schema!=='kloudy.strings/1'||!Array.isArray(value.strings)||value.strings.length>100)throw Error();return value;}catch(error){if(error.code==='ENOENT')return {schema:'kloudy.strings/1',strings:[]};fail('invalid_store','The saved strings could not be read. No data was replaced.');}}
18
- async mutate(work){await mkdir(dirname(this.file),{recursive:true,mode:0o700});const lock=this.file+'.lock';try{await mkdir(lock,{mode:0o700});}catch{fail('store_busy','Another strings operation is running. Retry after it finishes.');}try{const value=await this.read(),result=await work(value);const temp=this.file+'.'+randomUUID()+'.tmp';await writeFile(temp,JSON.stringify(value,null,2)+'\n',{flag:'wx',mode:0o600});await rename(temp,this.file);return result;}finally{await rmdir(lock);}}
19
- async save(name,values){if(!id(name)||!Array.isArray(values)||!values.length||values.length>16)fail('invalid_string','Name the string and choose 1–16 source selections.');const choices=[...new Map(values.map(v=>{const s=selection(v);return[JSON.stringify(s),s];})).values()];return this.mutate(db=>{const old=db.strings.find(s=>s.project===this.project&&s.name===name);if(old)fail('already_saved','This string already exists. Remove it explicitly before replacing it.');if(db.strings.length>=100)fail('store_full','Remove an unused string first.');const value={id:randomUUID(),name,project:this.project,choices,createdAt:this.now(),lastUsedAt:null,retention:'temporary',status:'saved'};db.strings.push(value);return value;});}
20
- async list(){return (await this.read()).strings.filter(s=>s.project===this.project);}
21
- async remove(name){return this.mutate(db=>{const index=db.strings.findIndex(s=>s.project===this.project&&s.name===name);if(index<0)fail('not_found','No string with that name exists in this project.');db.strings.splice(index,1);return {name,status:'removed',providerCredentialsRevoked:false};});}
22
- async keep(name){return this.mutate(db=>{const entry=db.strings.find(s=>s.project===this.project&&s.name===name);if(!entry)fail('not_found','No string with that name exists in this project.');entry.retention='kept';return {...entry,grantStatus:'not_requested',notice:'Saved for reuse. Permanent provider access still requires the existing GlassBreak authorization path.'};});}
23
- async prune({idleDays=30}={}){if(!Number.isInteger(idleDays)||idleDays<1||idleDays>365)fail('invalid_retention','Use 1–365 idle days.');return this.mutate(db=>{const removed=[];db.strings=db.strings.filter(s=>{const dormant=s.project===this.project&&s.retention!=='kept'&&this.now()-(s.lastUsedAt??s.createdAt)>=idleDays*86400000;if(dormant)removed.push(s.name);return !dormant;});return {removed,providerCredentialsRevoked:false};});}
24
- async use(name){
25
- const entry=(await this.list()).find(s=>s.name===name);if(!entry)fail('not_found','No string with that name exists in this project.');
26
- const tools=[],routes=[],selections=[],seen=new Map();
27
- for(const raw of entry.choices){const choice=selection(raw);const selected=await this.connect(choice.source,choice.kind,client=>client.ask(choice.goal));const definitions=selected?.toolbox?.tools;if(!Array.isArray(definitions)||definitions.length!==1||!selected.selection_id||!selected.sessionId)fail('selection_unavailable','A saved choice is unavailable or needs renewed authorization. No action ran.');
28
- const tool=definitions[0];if(typeof tool.name!=='string'||!tool.name||tool.name.length>128)fail('invalid_response','The source returned an invalid selected tool.');
29
- const identity=choice.source+'\n'+tool.name;let alias=seen.get(identity);
30
- if(!alias){if(tools.length===8)fail('toolbox_limit','This string selects more than eight tools. Split it into smaller task-specific strings.');alias='s'+tools.length+'_'+tool.name.replace(/[^a-zA-Z0-9_-]/g,'_');seen.set(identity,alias);tools.push({...tool,name:alias});}
31
- const route={name:alias,source:choice.source,kind:choice.kind,tool:tool.name,selection_id:selected.selection_id,sessionId:selected.sessionId};routes.push(route);selections.push({...route,arguments:selected.arguments});
32
- }
33
- await this.mutate(db=>{const latest=db.strings.find(s=>s.project===this.project&&s.name===name);if(!latest||latest.id!==entry.id)fail('removed_during_use','This string was removed while selecting. Select again.');latest.lastUsedAt=this.now();latest.activeRoutes=routes;latest.status='saved';});
34
- return {schema:'kloudy.toolbox/1',name,project:this.project,toolbox:{status:'ready',tools,measurements:{definitionsEmitted:tools.length}},routes,selections,notice:'Selections are session-scoped. No provider action was executed; the engine rechecks permission at call time.'};
35
- }
36
- async call(name,tool,inputs,{selectionId}={}){
37
- const entry=(await this.list()).find(s=>s.name===name),matches=entry?.activeRoutes?.filter(r=>r.name===tool&&(!selectionId||r.selection_id===selectionId))||[];
38
- if(matches.length>1)fail('ambiguous_selection','This tool has several owned selections. Choose the exact selection ID from the returned routes.');const route=matches[0];
39
- if(!route)fail('not_selected','Use this string first, then choose one exact returned tool alias.');
40
- return this.connect(route.source,route.kind,async client=>{await client.session();if(client.state.sessionId!==route.sessionId)fail('stale_selection','The connection session changed. Use this string again before calling a tool.');return client.call(route.tool,inputs,{selectionId:route.selection_id});});
41
- }
42
- }
43
- export function localStrings({project=process.cwd(),home=homedir(),mode='autonomous'}={}){
44
- const directory=resolve(home,'.kloudy');
45
- return new StringStore({file:resolve(directory,'strings.json'),project,connect:async(alias,kind,work)=>{
46
- let sources;try{sources=JSON.parse(await readFile(resolve(directory,'sources.json'),'utf8'));}catch{fail('sources_unavailable','Configure trusted source aliases in ~/.kloudy/sources.json first.');}
47
- const source=Object.hasOwn(sources,alias)?sources[alias]:null;
48
- if(!source||source.kind!==kind||typeof source.connection!=='string'||!source.connection)fail('source_unavailable','This source alias is missing or has a different adapter kind.');
49
- return withConnection(resolve(directory,source.connection),work,{mode});
50
- }});
51
- }
1
+ import {mkdir,readFile,writeFile,rename,rmdir} from 'node:fs/promises';
2
+ import {dirname,resolve} from 'node:path';
3
+ import {randomUUID} from 'node:crypto';
4
+ import {KloudyError} from './client.mjs';
5
+ import {homedir} from 'node:os';
6
+ import {withConnection} from './connection.mjs';
7
+ const fail=(code,message)=>{throw new KloudyError(code,message);};
8
+ const id=value=>typeof value==='string'&&/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/.test(value);
9
+ function selection(value){
10
+ if(!value||Object.keys(value).some(k=>!['source','kind','goal'].includes(k))||!id(value.source)||!['mcp','sdk','cli'].includes(value.kind)||typeof value.goal!=='string'||!value.goal.trim()||value.goal.length>500)fail('invalid_selection','Use a source alias, mcp/sdk/cli kind, and a goal of 1–500 characters. Credentials and tool definitions do not belong in a saved string.');
11
+ return {source:value.source,kind:value.kind,goal:value.goal.trim()};
12
+ }
13
+ // Durable intent references only. Engine-owned selections and schemas are refreshed
14
+ // at use time; this never replays a stale selection or invokes a provider action.
15
+ export class StringStore {
16
+ constructor({file,project,connect,now=()=>Date.now()}){if(!file||!project||typeof connect!=='function')fail('invalid_store','A private store, project ID and connection resolver are required.');this.file=resolve(file);this.project=resolve(project);this.connect=connect;this.now=now;}
17
+ async read(){try{const value=JSON.parse(await readFile(this.file,'utf8'));if(value.schema!=='kloudy.strings/1'||!Array.isArray(value.strings)||value.strings.length>100)throw Error();return value;}catch(error){if(error.code==='ENOENT')return {schema:'kloudy.strings/1',strings:[]};fail('invalid_store','The saved strings could not be read. No data was replaced.');}}
18
+ async mutate(work){await mkdir(dirname(this.file),{recursive:true,mode:0o700});const lock=this.file+'.lock';try{await mkdir(lock,{mode:0o700});}catch{fail('store_busy','Another strings operation is running. Retry after it finishes.');}try{const value=await this.read(),result=await work(value);const temp=this.file+'.'+randomUUID()+'.tmp';await writeFile(temp,JSON.stringify(value,null,2)+'\n',{flag:'wx',mode:0o600});await rename(temp,this.file);return result;}finally{await rmdir(lock);}}
19
+ async save(name,values){if(!id(name)||!Array.isArray(values)||!values.length||values.length>16)fail('invalid_string','Name the string and choose 1–16 source selections.');const choices=[...new Map(values.map(v=>{const s=selection(v);return[JSON.stringify(s),s];})).values()];return this.mutate(db=>{const old=db.strings.find(s=>s.project===this.project&&s.name===name);if(old)fail('already_saved','This string already exists. Remove it explicitly before replacing it.');if(db.strings.length>=100)fail('store_full','Remove an unused string first.');const value={id:randomUUID(),name,project:this.project,choices,createdAt:this.now(),lastUsedAt:null,retention:'temporary',status:'saved'};db.strings.push(value);return value;});}
20
+ async list(){return (await this.read()).strings.filter(s=>s.project===this.project);}
21
+ async remove(name){return this.mutate(db=>{const index=db.strings.findIndex(s=>s.project===this.project&&s.name===name);if(index<0)fail('not_found','No string with that name exists in this project.');db.strings.splice(index,1);return {name,status:'removed',providerCredentialsRevoked:false};});}
22
+ async keep(name){return this.mutate(db=>{const entry=db.strings.find(s=>s.project===this.project&&s.name===name);if(!entry)fail('not_found','No string with that name exists in this project.');entry.retention='kept';return {...entry,grantStatus:'not_requested',notice:'Saved for reuse. Permanent provider access still requires the existing GlassBreak authorization path.'};});}
23
+ async prune({idleDays=30}={}){if(!Number.isInteger(idleDays)||idleDays<1||idleDays>365)fail('invalid_retention','Use 1–365 idle days.');return this.mutate(db=>{const removed=[];db.strings=db.strings.filter(s=>{const dormant=s.project===this.project&&s.retention!=='kept'&&this.now()-(s.lastUsedAt??s.createdAt)>=idleDays*86400000;if(dormant)removed.push(s.name);return !dormant;});return {removed,providerCredentialsRevoked:false};});}
24
+ async use(name){
25
+ const entry=(await this.list()).find(s=>s.name===name);if(!entry)fail('not_found','No string with that name exists in this project.');
26
+ const tools=[],routes=[],selections=[],seen=new Map();
27
+ for(const raw of entry.choices){const choice=selection(raw);const selected=await this.connect(choice.source,choice.kind,client=>client.ask(choice.goal));const definitions=selected?.toolbox?.tools;if(!Array.isArray(definitions)||definitions.length!==1||!selected.selection_id||!selected.sessionId)fail('selection_unavailable','A saved choice is unavailable or needs renewed authorization. No action ran.');
28
+ const tool=definitions[0];if(typeof tool.name!=='string'||!tool.name||tool.name.length>128)fail('invalid_response','The source returned an invalid selected tool.');
29
+ const identity=choice.source+'\n'+tool.name;let alias=seen.get(identity);
30
+ if(!alias){if(tools.length===8)fail('toolbox_limit','This string selects more than eight tools. Split it into smaller task-specific strings.');alias='s'+tools.length+'_'+tool.name.replace(/[^a-zA-Z0-9_-]/g,'_');seen.set(identity,alias);tools.push({...tool,name:alias});}
31
+ const route={name:alias,source:choice.source,kind:choice.kind,tool:tool.name,selection_id:selected.selection_id,sessionId:selected.sessionId};routes.push(route);selections.push({...route,arguments:selected.arguments});
32
+ }
33
+ await this.mutate(db=>{const latest=db.strings.find(s=>s.project===this.project&&s.name===name);if(!latest||latest.id!==entry.id)fail('removed_during_use','This string was removed while selecting. Select again.');latest.lastUsedAt=this.now();latest.activeRoutes=routes;latest.status='saved';});
34
+ return {schema:'kloudy.toolbox/1',name,project:this.project,toolbox:{status:'ready',tools,measurements:{definitionsEmitted:tools.length}},routes,selections,notice:'Selections are session-scoped. No provider action was executed; the engine rechecks permission at call time.'};
35
+ }
36
+ async call(name,tool,inputs,{selectionId}={}){
37
+ const entry=(await this.list()).find(s=>s.name===name),matches=entry?.activeRoutes?.filter(r=>r.name===tool&&(!selectionId||r.selection_id===selectionId))||[];
38
+ if(matches.length>1)fail('ambiguous_selection','This tool has several owned selections. Choose the exact selection ID from the returned routes.');const route=matches[0];
39
+ if(!route)fail('not_selected','Use this string first, then choose one exact returned tool alias.');
40
+ return this.connect(route.source,route.kind,async client=>{await client.session();if(client.state.sessionId!==route.sessionId)fail('stale_selection','The connection session changed. Use this string again before calling a tool.');return client.call(route.tool,inputs,{selectionId:route.selection_id});});
41
+ }
42
+ }
43
+ export function localStrings({project=process.cwd(),home=homedir(),mode='autonomous'}={}){
44
+ const directory=resolve(home,'.kloudy');
45
+ return new StringStore({file:resolve(directory,'strings.json'),project,connect:async(alias,kind,work)=>{
46
+ let sources;try{sources=JSON.parse(await readFile(resolve(directory,'sources.json'),'utf8'));}catch{fail('sources_unavailable','Configure trusted source aliases in ~/.kloudy/sources.json first.');}
47
+ const source=Object.hasOwn(sources,alias)?sources[alias]:null;
48
+ if(!source||source.kind!==kind||typeof source.connection!=='string'||!source.connection)fail('source_unavailable','This source alias is missing or has a different adapter kind.');
49
+ return withConnection(resolve(directory,source.connection),work,{mode});
50
+ }});
51
+ }
package/src/wrap.mjs CHANGED
@@ -1,58 +1,58 @@
1
- import {createServer} from 'node:http';
2
- import {createHash,timingSafeEqual} from 'node:crypto';
3
- import {Client} from '@modelcontextprotocol/sdk/client/index.js';
4
- import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js';
5
- import {Server} from '@modelcontextprotocol/sdk/server/index.js';
6
- import {StreamableHTTPServerTransport} from '@modelcontextprotocol/sdk/server/streamableHttp.js';
7
- import {ListToolsRequestSchema,CallToolRequestSchema} from '@modelcontextprotocol/sdk/types.js';
8
- import {KloudyError} from './client.mjs';
9
- const hash=x=>createHash('sha256').update(x).digest();
10
- export function createWrapper({command,args=[],tools,token,env={},timeout=10000,maxConcurrent=2}){
11
- if(typeof command!=='string'||!command||!Array.isArray(args)||args.some(x=>typeof x!=='string')||!Array.isArray(tools)||!tools.length||tools.length>8||tools.some(x=>typeof x!=='string'||!/^[-\w.]{1,100}$/.test(x))||typeof token!=='string'||token.length<24)throw new KloudyError('invalid_wrapper','Specify an approved command, 1–8 selected tools and a strong bearer from the local host.');
12
- const allowed=new Set(tools);let active=0;
13
- return createServer(async(req,res)=>{
14
- const send=(status,error)=>{if(!res.headersSent){res.writeHead(status,{'content-type':'application/json','cache-control':'no-store'});res.end(JSON.stringify({error}));}};
15
- // Loopback-only host and no browser-origin requests. TLS/public grant policy belongs at the owning deployment edge.
16
- if(!/^(127\.0\.0\.1|localhost|\[::1\])(?::\d+)?$/.test(req.headers.host||'')||req.headers.origin)return send(403,'origin_not_allowed');
17
- if(req.url!=='/mcp')return send(404,'not_found');
18
- if(!timingSafeEqual(hash(req.headers.authorization||''),hash('Bearer '+token)))return send(401,'unauthorized');
19
- if(req.method!=='POST')return send(405,'method_not_allowed');
20
- if(!req.headers['content-type']?.startsWith('application/json'))return send(415,'json_required');
21
- if(active>=maxConcurrent)return send(429,'busy');
22
- active++;let upstream,server,transport,timer;
23
- try{
24
- let data='';for await(const chunk of req){data+=chunk;if(Buffer.byteLength(data)>65536)return send(413,'too_large');}
25
- let body;try{body=JSON.parse(data);}catch{return send(400,'invalid_json');}
26
- if(Array.isArray(body)||!body||body.jsonrpc!=='2.0')return send(400,'invalid_request');
27
- if(body.method==='tools/call'&&!allowed.has(body.params?.name))return send(403,'tool_not_selected');
28
- if(!['initialize','notifications/initialized','tools/list','tools/call','ping'].includes(body.method))return send(400,'unsupported_method');
29
- const client=new Client({name:'kloudy-httpk-wrap',version:'0.1.0'});
30
- upstream=new StdioClientTransport({command,args,env,stderr:'pipe'});
31
- // No upstream stderr or credential values are reflected into HTTP responses or logs.
32
- upstream.stderr?.on('data',()=>{});
33
- timer=setTimeout(()=>{send(504,'upstream_timeout');upstream.close().catch(()=>{});},timeout);
34
- await client.connect(upstream);
35
- let callResult;
36
- if(body.method==='tools/call')callResult=await client.callTool(body.params,undefined,{timeout});
37
- server=new Server({name:'kloudy-httpk',version:'0.1.0'},{capabilities:{tools:{}}});
38
- server.setRequestHandler(ListToolsRequestSchema,async()=>{
39
- const selected=[];let cursor;
40
- for(let page=0;page<10;page++){
41
- const value=await client.listTools(cursor?{cursor}:undefined);
42
- for(const tool of value.tools)if(allowed.has(tool.name))selected.push({name:tool.name,description:tool.description,inputSchema:tool.inputSchema});
43
- cursor=value.nextCursor;if(!cursor)break;
44
- if(page===9)throw new Error('Catalog limit');
45
- }
46
- return {tools:selected};
47
- });
48
- server.setRequestHandler(CallToolRequestSchema,async request=>{
49
- if(!allowed.has(request.params.name))throw new Error('Tool not selected');
50
- return callResult;
51
- });
52
- transport=new StreamableHTTPServerTransport({sessionIdGenerator:undefined,enableJsonResponse:true});
53
- await server.connect(transport);
54
- await transport.handleRequest(req,res,body);
55
- }catch{send(502,'upstream_unavailable');}
56
- finally{clearTimeout(timer);await transport?.close().catch(()=>{});await server?.close().catch(()=>{});await upstream?.close().catch(()=>{});active--;}
57
- });
58
- }
1
+ import {createServer} from 'node:http';
2
+ import {createHash,timingSafeEqual} from 'node:crypto';
3
+ import {Client} from '@modelcontextprotocol/sdk/client/index.js';
4
+ import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js';
5
+ import {Server} from '@modelcontextprotocol/sdk/server/index.js';
6
+ import {StreamableHTTPServerTransport} from '@modelcontextprotocol/sdk/server/streamableHttp.js';
7
+ import {ListToolsRequestSchema,CallToolRequestSchema} from '@modelcontextprotocol/sdk/types.js';
8
+ import {KloudyError} from './client.mjs';
9
+ const hash=x=>createHash('sha256').update(x).digest();
10
+ export function createWrapper({command,args=[],tools,token,env={},timeout=10000,maxConcurrent=2}){
11
+ if(typeof command!=='string'||!command||!Array.isArray(args)||args.some(x=>typeof x!=='string')||!Array.isArray(tools)||!tools.length||tools.length>8||tools.some(x=>typeof x!=='string'||!/^[-\w.]{1,100}$/.test(x))||typeof token!=='string'||token.length<24)throw new KloudyError('invalid_wrapper','Specify an approved command, 1–8 selected tools and a strong bearer from the local host.');
12
+ const allowed=new Set(tools);let active=0;
13
+ return createServer(async(req,res)=>{
14
+ const send=(status,error)=>{if(!res.headersSent){res.writeHead(status,{'content-type':'application/json','cache-control':'no-store'});res.end(JSON.stringify({error}));}};
15
+ // Loopback-only host and no browser-origin requests. TLS/public grant policy belongs at the owning deployment edge.
16
+ if(!/^(127\.0\.0\.1|localhost|\[::1\])(?::\d+)?$/.test(req.headers.host||'')||req.headers.origin)return send(403,'origin_not_allowed');
17
+ if(req.url!=='/mcp')return send(404,'not_found');
18
+ if(!timingSafeEqual(hash(req.headers.authorization||''),hash('Bearer '+token)))return send(401,'unauthorized');
19
+ if(req.method!=='POST')return send(405,'method_not_allowed');
20
+ if(!req.headers['content-type']?.startsWith('application/json'))return send(415,'json_required');
21
+ if(active>=maxConcurrent)return send(429,'busy');
22
+ active++;let upstream,server,transport,timer;
23
+ try{
24
+ let data='';for await(const chunk of req){data+=chunk;if(Buffer.byteLength(data)>65536)return send(413,'too_large');}
25
+ let body;try{body=JSON.parse(data);}catch{return send(400,'invalid_json');}
26
+ if(Array.isArray(body)||!body||body.jsonrpc!=='2.0')return send(400,'invalid_request');
27
+ if(body.method==='tools/call'&&!allowed.has(body.params?.name))return send(403,'tool_not_selected');
28
+ if(!['initialize','notifications/initialized','tools/list','tools/call','ping'].includes(body.method))return send(400,'unsupported_method');
29
+ const client=new Client({name:'kloudy-httpk-wrap',version:'0.1.0'});
30
+ upstream=new StdioClientTransport({command,args,env,stderr:'pipe'});
31
+ // No upstream stderr or credential values are reflected into HTTP responses or logs.
32
+ upstream.stderr?.on('data',()=>{});
33
+ timer=setTimeout(()=>{send(504,'upstream_timeout');upstream.close().catch(()=>{});},timeout);
34
+ await client.connect(upstream);
35
+ let callResult;
36
+ if(body.method==='tools/call')callResult=await client.callTool(body.params,undefined,{timeout});
37
+ server=new Server({name:'kloudy-httpk',version:'0.1.0'},{capabilities:{tools:{}}});
38
+ server.setRequestHandler(ListToolsRequestSchema,async()=>{
39
+ const selected=[];let cursor;
40
+ for(let page=0;page<10;page++){
41
+ const value=await client.listTools(cursor?{cursor}:undefined);
42
+ for(const tool of value.tools)if(allowed.has(tool.name))selected.push({name:tool.name,description:tool.description,inputSchema:tool.inputSchema});
43
+ cursor=value.nextCursor;if(!cursor)break;
44
+ if(page===9)throw new Error('Catalog limit');
45
+ }
46
+ return {tools:selected};
47
+ });
48
+ server.setRequestHandler(CallToolRequestSchema,async request=>{
49
+ if(!allowed.has(request.params.name))throw new Error('Tool not selected');
50
+ return callResult;
51
+ });
52
+ transport=new StreamableHTTPServerTransport({sessionIdGenerator:undefined,enableJsonResponse:true});
53
+ await server.connect(transport);
54
+ await transport.handleRequest(req,res,body);
55
+ }catch{send(502,'upstream_unavailable');}
56
+ finally{clearTimeout(timer);await transport?.close().catch(()=>{});await server?.close().catch(()=>{});await upstream?.close().catch(()=>{});active--;}
57
+ });
58
+ }