@tomflow/chatloop-tools 0.0.0-stage → 0.4.0

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.
@@ -0,0 +1,71 @@
1
+ // Stdio MCP entrypoint: protocol framing and routing only; never owns child processes.
2
+ import {randomUUID} from 'node:crypto';
3
+ import {readFileSync} from 'node:fs';
4
+ import {CONTRACT,registry,talk} from './control.mjs';
5
+
6
+ const desktopTools=JSON.parse(readFileSync(new URL('./desktop-commander-tools.json',import.meta.url),'utf8'));
7
+ const fixtureTool={name:'ping',description:'Inert lifecycle test health check',
8
+ inputSchema:{type:'object',properties:{},additionalProperties:false}};
9
+ const toolsFor=t=>t.kind==='mcp-child'?desktopTools:t.kind==='fixture'?[fixtureTool]:[];
10
+ const prefix=id=>id.replaceAll('-','_')+'__';
11
+ const published=()=>registry().filter(t=>t.enabled).flatMap(t=>
12
+ toolsFor(t).map(x=>({...x,name:prefix(t.id)+x.name})));
13
+ function resolveTool(name){
14
+ for(const t of registry().filter(x=>x.enabled)){
15
+ const p=prefix(t.id);
16
+ if(typeof name==='string'&&name.startsWith(p)){
17
+ const original=name.slice(p.length);
18
+ if(toolsFor(t).some(x=>x.name===original))return {toolId:t.id,name:original};
19
+ }
20
+ }
21
+ return null;
22
+ }
23
+ const failure=code=>({isError:true,content:[{type:'text',text:code}]});
24
+ const rpcError=(code,message)=>({code,message});
25
+ export function runGateway(){
26
+ let buf='',connected=false;
27
+ const write=message=>process.stdout.write(JSON.stringify(message)+'\n');
28
+ const reply=(id,result)=>write({jsonrpc:'2.0',id,result});
29
+ const reject=(id,code,message)=>write({jsonrpc:'2.0',id,error:rpcError(code,message)});
30
+ const processMessage=async m=>{
31
+ if(!m||m.jsonrpc!=='2.0'||m.id===undefined)return;
32
+ if(m.method==='initialize'){
33
+ if(!['2025-03-26','2024-11-05'].includes(m.params?.protocolVersion))
34
+ return reject(m.id,-32602,'UNSUPPORTED_MCP_VERSION');
35
+ connected=true;
36
+ return reply(m.id,{protocolVersion:m.params.protocolVersion,
37
+ capabilities:{tools:{}},serverInfo:{name:'chat-loop-local-tools',version:'0.4.0'}});
38
+ }
39
+ if(!connected)return reject(m.id,-32002,'NOT_INITIALIZED');
40
+ if(m.method==='ping')return reply(m.id,{});
41
+ if(m.method==='tools/list')return reply(m.id,{tools:published()});
42
+ if(m.method==='tools/call'){
43
+ const routed=resolveTool(m.params?.name);
44
+ if(!routed)return reply(m.id,failure('UNKNOWN_TOOL'));
45
+ const args=m.params?.arguments??{};
46
+ if(args===null||typeof args!=='object'||Array.isArray(args))
47
+ return reply(m.id,failure('INVALID_ARGUMENT'));
48
+ try{
49
+ const res=await talk({contract:CONTRACT,requestId:randomUUID(),action:'call',
50
+ scope:'tool',toolId:routed.toolId,name:routed.name,args,deadlineMs:12000},13000);
51
+ if(res.status!=='OK')return reply(m.id,failure(res.error?.code||'TOOL_UNAVAILABLE'));
52
+ return reply(m.id,res.result);
53
+ }catch(e){
54
+ return reply(m.id,failure(['ENOENT','ECONNREFUSED'].includes(e.code)?'TOOL_UNAVAILABLE':
55
+ e.code||'MANAGER_UNAVAILABLE'));
56
+ }
57
+ }
58
+ return reject(m.id,-32601,'METHOD_NOT_FOUND');
59
+ };
60
+ process.stdin.setEncoding('utf8');
61
+ process.stdin.on('data',chunk=>{
62
+ buf+=chunk;
63
+ if(buf.length>1024*1024){process.stderr.write('MCP frame too large\n');process.exitCode=1;process.stdin.pause();return;}
64
+ let n;
65
+ while((n=buf.indexOf('\n'))>=0){
66
+ const line=buf.slice(0,n);buf=buf.slice(n+1);
67
+ let m;try{m=JSON.parse(line);}catch{continue;}
68
+ void processMessage(m).catch(e=>{if(m?.id!==undefined)reject(m.id,-32603,'INTERNAL_ERROR');});
69
+ }
70
+ });
71
+ }
@@ -0,0 +1,163 @@
1
+ import {randomUUID} from 'node:crypto';
2
+ import {appendFileSync, closeSync, existsSync, openSync, readFileSync, unlinkSync, writeFileSync, chmodSync} from 'node:fs';
3
+ import {createServer} from 'node:net';
4
+ import {CONTRACT,VERSION,registry,paths,ensurePrivateDir,ACTIONS} from './control.mjs';
5
+ import {createAdapter} from './adapters/index.mjs';
6
+
7
+ const catalog=registry(), records=new Map(), queues=new Map(), seen=new Map();
8
+ const p=paths(), nonce=randomUUID();
9
+ let lockFd, server, idleTimer;
10
+ const log=(event,detail={})=>{
11
+ try{appendFileSync(p.log,JSON.stringify({time:new Date().toISOString(),event,...detail})+'\n',{mode:0o600});}catch{}
12
+ };
13
+ const error=(code,message=code)=>({code,message});
14
+ const after=rec=>rec ? rec.state : 'STOPPED';
15
+ function serial(id,fn){
16
+ const prev=queues.get(id)||Promise.resolve();
17
+ const next=prev.catch(()=>{}).then(fn);
18
+ queues.set(id,next);
19
+ next.finally(()=>{if(queues.get(id)===next)queues.delete(id);}).catch(()=>{});
20
+ return next;
21
+ }
22
+ const adapters=new Map();
23
+ function adapterFor(tool){
24
+ if(!adapters.has(tool.kind))adapters.set(tool.kind,createAdapter(tool.kind,{records,log}));
25
+ return adapters.get(tool.kind);
26
+ }
27
+ function targets(req){
28
+ const found=req.scope==='tool'?catalog.filter(t=>t.id===req.toolId):
29
+ catalog.filter(t=>req.action==='status'||t.enabled);
30
+ if(req.scope==='tool' && !found.length)throw error('UNKNOWN_TOOL');
31
+ if(req.action!=='status' && found.some(t=>!t.enabled))throw error('DISABLED_TOOL');
32
+ if(req.action!=='status' && !found.length)throw error('NO_ENABLED_TOOLS');
33
+ return found;
34
+ }
35
+ const bad=(req,code)=>({contract:CONTRACT,requestId:req?.requestId||null,action:req?.action||null,
36
+ scope:req?.scope||null,target:req?.toolId||null,status:'FAILED',results:[],error:error(code)});
37
+ async function executeCall(req){
38
+ const invalid=()=>bad(req,'INVALID_ARGUMENT');
39
+ if(req.contract!==CONTRACT)return bad(req,'INCOMPATIBLE_VERSION');
40
+ if(typeof req.requestId!=='string'||req.requestId.length<6||req.requestId.length>128||
41
+ req.scope!=='tool'||typeof req.toolId!=='string'||typeof req.name!=='string'||
42
+ !req.args||typeof req.args!=='object'||Array.isArray(req.args)||
43
+ !Number.isInteger(req.deadlineMs)||req.deadlineMs<1000||req.deadlineMs>30000)return invalid();
44
+ const tool=catalog.find(t=>t.id===req.toolId);
45
+ if(!tool)return bad(req,'UNKNOWN_TOOL');
46
+ if(!tool.enabled)return bad(req,'DISABLED_TOOL');
47
+ const argsKey=JSON.stringify([req.action,req.scope,req.toolId,req.name,req.args,req.deadlineMs]);
48
+ const prev=seen.get(req.requestId);
49
+ if(prev)return prev.payload===argsKey?prev.promise:bad(req,'REQUEST_ID_REUSE');
50
+ const expires=Date.now()+req.deadlineMs;
51
+ const promise=serial(tool.id,async()=>{
52
+ try{
53
+ const adapter=adapterFor(tool);
54
+ if(typeof adapter.call!=='function')return bad(req,'TOOL_UNAVAILABLE');
55
+ const value=await adapter.call(tool,req.name,req.args,
56
+ {requestId:req.requestId,remaining:()=>Math.max(0,expires-Date.now())});
57
+ log('tool-call',{requestId:req.requestId,toolId:tool.id,toolName:req.name,status:'OK'});
58
+ return {contract:CONTRACT,requestId:req.requestId,status:'OK',result:value};
59
+ }catch(e){
60
+ const code=e.code||'UNKNOWN_RESULT';
61
+ log('tool-call',{requestId:req.requestId,toolId:tool.id,toolName:req.name,status:'FAILED',errorCode:code});
62
+ return bad(req,code);
63
+ }
64
+ });
65
+ seen.set(req.requestId,{payload:argsKey,promise});
66
+ if(seen.size>128)seen.delete(seen.keys().next().value);
67
+ return promise;
68
+ }
69
+ async function execute(req){
70
+ if(req.contract!==CONTRACT)return bad(req,'INCOMPATIBLE_VERSION');
71
+ if(typeof req.requestId!=='string'||req.requestId.length<6||req.requestId.length>128||
72
+ !ACTIONS.has(req.action)||!['all','tool'].includes(req.scope)||
73
+ (req.scope==='tool' && typeof req.toolId!=='string')||
74
+ (req.scope==='all' && req.toolId!==undefined)||
75
+ !Number.isInteger(req.deadlineMs)||req.deadlineMs<1000||req.deadlineMs>30000)return bad(req,'INVALID_ARGUMENT');
76
+ let found;try{found=targets(req);}catch(e){return bad(req,e.code);}
77
+ const payload=JSON.stringify([req.action,req.scope,req.toolId||null,req.deadlineMs]);
78
+ const hit=seen.get(req.requestId);
79
+ if(hit)return hit.payload===payload?hit.promise:bad(req,'REQUEST_ID_REUSE');
80
+ // Read-only status must never cancel idle shutdown.
81
+ if(req.action==='start'||req.action==='restart')clearTimeout(idleTimer);
82
+ const expiresAt=Date.now()+req.deadlineMs;
83
+ const ctx={requestId:req.requestId,remaining:()=>Math.max(0,expiresAt-Date.now())};
84
+ const promise=(async()=>{
85
+ const results=[];
86
+ // Batch targets run in Registry order; each tool is separately serialized.
87
+ for(const t of found){
88
+ const item=await serial(t.id,async()=>{
89
+ if(req.action==='status'){
90
+ if(!t.enabled)return {toolId:t.id,enabled:false,before:'DISABLED',after:'DISABLED',
91
+ outcome:'OBSERVED',pid:null,evidence:'REGISTRY',error:null};
92
+ return adapterFor(t).inspect(t,ctx);
93
+ }
94
+ if(req.action==='start')return adapterFor(t).launch(t,ctx);
95
+ if(req.action==='stop')return adapterFor(t).stop(t,ctx);
96
+ const ended=await adapterFor(t).stop(t,ctx);
97
+ if(ended.error)return {...ended,outcome:'RESTART_FAILED'};
98
+ const began=await adapterFor(t).launch(t,ctx);
99
+ return {...began,before:ended.before,outcome:began.error?'RESTART_FAILED':'RESTARTED'};
100
+ });
101
+ results.push(item);
102
+ }
103
+ const failures=results.filter(r=>r.error);
104
+ const status=results.some(r=>r.after==='UNKNOWN')?'UNKNOWN':failures.length===0?'OK':failures.length===results.length?'FAILED':'PARTIAL';
105
+ const response={contract:CONTRACT,requestId:req.requestId,action:req.action,scope:req.scope,target:req.toolId||null,status,results,managerPid:process.pid};
106
+ log('control',{requestId:req.requestId,action:req.action,scope:req.scope,toolId:req.toolId||null,status,
107
+ results:results.map(r=>({toolId:r.toolId,after:r.after,outcome:r.outcome,errorCode:r.error?.code||null}))});
108
+ if(req.action==='stop' && ![...records.values()].some(r=>!r.exited)){
109
+ idleTimer=setTimeout(()=>shutdown(),3000);idleTimer.unref();
110
+ }
111
+ return response;
112
+ })();
113
+ seen.set(req.requestId,{payload,promise});
114
+ if(seen.size>128)seen.delete(seen.keys().next().value);
115
+ return promise;
116
+ }
117
+ async function shutdown(){
118
+ if([...records.values()].some(r=>!r.exited))return;
119
+ server?.close();
120
+ try{if(existsSync(p.socket))unlinkSync(p.socket);}catch{}
121
+ try{if(existsSync(p.lock) && JSON.parse(readFileSync(p.lock,'utf8')).nonce===nonce)unlinkSync(p.lock);}catch{}
122
+ try{if(lockFd!==undefined)closeSync(lockFd);}catch{}
123
+ process.exit(0);
124
+ }
125
+ async function main(){
126
+ ensurePrivateDir();
127
+ try{lockFd=openSync(p.lock,'wx',0o600);}
128
+ catch(err){if(err.code==='EEXIST')process.exit(0);throw err;}
129
+ writeFileSync(lockFd,JSON.stringify({pid:process.pid,nonce,version:VERSION,at:Date.now()}));
130
+ // A lock ensures no other manager may own this socket.
131
+ if(existsSync(p.socket)){
132
+ const s=(await import('node:fs')).lstatSync(p.socket);
133
+ if(!s.isSocket() || (process.getuid && s.uid!==process.getuid()))throw Error('Unknown socket ownership');
134
+ unlinkSync(p.socket);
135
+ }
136
+ server=createServer(socket=>{
137
+ let buf='', handled=false;
138
+ socket.on('data',chunk=>{
139
+ if(handled)return;
140
+ buf+=chunk.toString();
141
+ if(buf.length>1048576){socket.destroy();return;}
142
+ const n=buf.indexOf('\n');if(n<0)return;
143
+ handled=true;
144
+ let req;try{req=JSON.parse(buf.slice(0,n));}catch{socket.end(JSON.stringify(bad({},'INVALID_ARGUMENT'))+'\n');return;}
145
+ const work=req?.action==='__ping'?
146
+ Promise.resolve({contract:CONTRACT,version:VERSION,registrySignature:catalog.map(t=>t.id+':'+t.enabled).join('|'),ownerNonce:nonce,managerPid:process.pid}):req?.action==='call'?executeCall(req):execute(req);
147
+ work.then(value=>socket.end(JSON.stringify(value)+'\n')).catch(err=>{
148
+ log('ipc-error',{code:err.code||'INTERNAL_ERROR'});
149
+ socket.end(JSON.stringify(bad(req,'INTERNAL_ERROR'))+'\n');
150
+ });
151
+ });
152
+ socket.on('error',()=>{});
153
+ });
154
+ await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(p.socket,resolve);});
155
+ chmodSync(p.socket,0o600);
156
+ log('manager-start',{pid:process.pid,version:VERSION});
157
+ process.on('SIGTERM',async()=>{
158
+ for(const t of catalog.filter(t=>t.enabled))
159
+ await serial(t.id,()=>adapterFor(t).stop(t,{requestId:'manager-shutdown',remaining:()=>5000}));
160
+ await shutdown();
161
+ });
162
+ }
163
+ main().catch(err=>{log('manager-fatal',{code:err.code||'ERROR'});process.exit(1);});
package/package.json CHANGED
@@ -1,6 +1,17 @@
1
1
  {
2
2
  "name": "@tomflow/chatloop-tools",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "publishConfig": { "access": "public" },
4
+ "version": "0.4.0",
5
+ "type": "module",
6
+ "description": "Chat Loop local tools: MCP gateway and unified tool lifecycle CLI",
7
+ "bin": { "chatloop-tools": "./bin/cli.mjs", "chat-loop-local": "./bin/cli.mjs" },
8
+ "files": ["bin/", "lib/", "fixtures/", "README.md"],
9
+ "scripts": {
10
+ "doctor": "node ./bin/cli.mjs doctor",
11
+ "test": "node --test ./test/*.test.mjs"
12
+ },
13
+ "dependencies": {
14
+ "@wonderwhy-er/desktop-commander": "0.2.44"
15
+ },
16
+ "engines": { "node": ">=20" }
17
+ }