@homie-rocks/studio 0.38.0 → 0.40.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.
- package/CHANGELOG.md +57 -1
- package/bin/homie-studio.mjs +13 -1
- package/lib/build.mjs +4 -0
- package/lib/cloudflare.mjs +2 -2
- package/lib/dev.mjs +12 -1
- package/lib/mcp-tools.mjs +10 -0
- package/lib/port-check.mjs +33 -7
- package/lib/rules-build.mjs +8 -1
- package/lib/rules-check-worker.mjs +3 -3
- package/lib/rules-check.mjs +3 -1
- package/lib/scaffold.mjs +19 -6
- package/lib/studio.mjs +1 -1
- package/lib/template-history.json +82 -0
- package/lib/tools-build.mjs +55 -0
- package/lib/tools-cli.mjs +55 -0
- package/lib/worker-build.mjs +35 -0
- package/netplay/NETPLAY.md +74 -2
- package/package.json +17 -6
- package/rules/collision.ts +115 -0
- package/rules/core.ts +65 -25
- package/rules/host.ts +5 -1
- package/rules/math.ts +15 -5
- package/rules/rules.ts +33 -10
- package/rules/types.ts +2 -2
- package/rules/view.ts +126 -32
- package/starters/ember-vale/game.json +16 -4
- package/starters/ember-vale/map/main.json +66 -0
- package/starters/ember-vale/src/move.ts +31 -0
- package/starters/ember-vale/src/rules.ts +244 -0
- package/starters/ember-vale/src/{main.ts → view.ts} +89 -574
- package/starters/ember-vale/tunables.json +100 -11
- package/starters/gem-rush/src/move.ts +9 -1
- package/starters/gem-rush/src/view.ts +2 -1
- package/starters/gem-rush-3d/game.json +23 -5
- package/starters/gem-rush-3d/map/main.json +153 -0
- package/starters/gem-rush-3d/src/move.ts +37 -0
- package/starters/gem-rush-3d/src/rules.ts +133 -0
- package/starters/gem-rush-3d/src/{main.ts → view.ts} +63 -671
- package/starters/gem-rush-3d/tunables.json +98 -11
- package/starters/hero-rush-3d/game.json +23 -5
- package/starters/hero-rush-3d/map/main.json +153 -0
- package/starters/hero-rush-3d/src/move.ts +49 -0
- package/starters/hero-rush-3d/src/rules.ts +200 -0
- package/starters/hero-rush-3d/src/{main.ts → view.ts} +83 -976
- package/starters/hero-rush-3d/tunables.json +260 -30
- package/tools/TOOLS.md +113 -0
- package/tools/tools.ts +28 -0
- package/worker/app-records.mjs +8 -0
- package/worker/index.mjs +26 -0
- package/worker/mcp-room.mjs +61 -0
- package/worker/mcp-store.mjs +45 -0
- package/worker/mcp-tools.mjs +105 -0
- package/worker/mcp.mjs +159 -0
- package/worker/office-page.mjs +1 -1
- package/worker/office.mjs +2 -1
- package/worker/purchase-mcp.mjs +3 -3
- package/worker/tool-events.mjs +14 -0
- package/worker/tool-identity.mjs +4 -0
- package/worker/tool-webhook.mjs +28 -0
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { withToolIdentity } from './tool-identity.mjs';
|
|
2
|
+
import { officeRoutes } from './office.mjs';
|
|
3
|
+
import { appRecordsRoute } from './app-records.mjs';
|
|
4
|
+
import { players } from './players.mjs';
|
|
5
|
+
import { readStats, rangeOf } from './stats.mjs';
|
|
6
|
+
import { publicCatalogue, settingsOf, launchOf } from './office.mjs';
|
|
7
|
+
export const object = (properties = {}, required = []) => ({type:'object',properties,required,additionalProperties:false});
|
|
8
|
+
const str = {type:'string',maxLength:128};
|
|
9
|
+
export async function allowed(audience, caller, env) {
|
|
10
|
+
if (audience === 'public') return true;
|
|
11
|
+
if (!caller.id) return false;
|
|
12
|
+
if (audience === 'signed-in') return true;
|
|
13
|
+
if (audience === 'owner') return caller.owner === true;
|
|
14
|
+
if (audience && typeof audience === 'object' && audience.app && audience.role) {
|
|
15
|
+
return caller.owner || Boolean(await env.DB.prepare('SELECT player FROM app_roles WHERE app=?1 AND role=?2 AND player=?3').bind(audience.app,audience.role,caller.id).first());
|
|
16
|
+
}
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
export async function currentCaller(env, props) {
|
|
20
|
+
if (!props?.connection) return {id:null,owner:false,client:null};
|
|
21
|
+
const row = await env.DB.prepare('SELECT * FROM mcp_connections WHERE id=?1 AND revoked=0').bind(props.connection).first();
|
|
22
|
+
if (!row) throw new Error('connection revoked');
|
|
23
|
+
if (row.person.startsWith('office-')) {
|
|
24
|
+
const key = await env.DB.prepare("SELECT kind FROM stats_keys WHERE hash=?1 AND kind='session' AND expires_at>?2").bind(row.person.slice(7),Date.now()).first();
|
|
25
|
+
if (!key) throw new Error('owner session expired');
|
|
26
|
+
return {id:row.person,owner:true,client:row.client,connection:row.id};
|
|
27
|
+
}
|
|
28
|
+
const p = await env.DB.prepare('SELECT id,name,owner,guest FROM players WHERE id=?1').bind(row.person).first();
|
|
29
|
+
if (!p || p.guest) throw new Error('account unavailable');
|
|
30
|
+
const grants=(await env.DB.prepare('SELECT app,role FROM app_roles WHERE player=?1').bind(p.id).all()).results;
|
|
31
|
+
const roles=Object.create(null);for(const grant of grants??[])(roles[grant.app]??=[]).push(grant.role);
|
|
32
|
+
return {id:p.id,name:p.name,owner:p.owner===1,roles,client:row.client,connection:row.id};
|
|
33
|
+
}
|
|
34
|
+
export function services(env, cat, origin, caller, namespace, definition = {}) {
|
|
35
|
+
const records = async ({app,role,collection,id,operation='read',data,version}) => {
|
|
36
|
+
const meta = cat.games.find(g=>g.id===app && g.kind==='app');
|
|
37
|
+
if (!meta || !/^[a-z][a-z0-9-]{0,39}$/.test(collection) || (id!==undefined && !/^[A-Za-z0-9_-]{1,64}$/.test(id))) throw new Error('unknown app, collection or record');
|
|
38
|
+
if (!caller.owner && launchOf(meta,await settingsOf(env,{fresh:true}),env)!=='public') throw new Error('app is private');
|
|
39
|
+
const url = new URL(`/${app}/api/app/records/${collection}${id ? '/'+id : ''}`,origin);if(role)url.searchParams.set('role',role);
|
|
40
|
+
const method = {read:'GET',create:'POST',update:'PUT'}[operation];if (!method) throw new Error('unsupported operation');
|
|
41
|
+
const request = withToolIdentity(new Request(url,{method,headers:{origin,'content-type':'application/json'},...(method==='GET'?{}:{body:JSON.stringify({data,version})})}),caller);
|
|
42
|
+
const response = await appRecordsRoute(request,env,meta,url,url.pathname.slice(app.length+2));
|
|
43
|
+
const result = await response.json(); if(!response.ok) throw new Error(result.error);return result;
|
|
44
|
+
};
|
|
45
|
+
const office = async (path, body, query={}) => {
|
|
46
|
+
if (!caller.owner || !/^\/_studio\/api\//.test(path) || path.includes('..') || path.includes('?')) throw new Error('owner only');
|
|
47
|
+
const url = new URL(path,origin);
|
|
48
|
+
for(const [key,value] of Object.entries(query))url.searchParams.set(key,String(value));
|
|
49
|
+
const request = withToolIdentity(new Request(url,{method:body?'POST':'GET',headers:{origin,'content-type':'application/json'},...(body?{body:JSON.stringify(body)}:{})}),caller);
|
|
50
|
+
const response = await officeRoutes(request,env,url,{catalogueOf:async()=>cat});
|
|
51
|
+
const result = response.headers.get('content-type')?.includes('application/json')?await response.json():{text:await response.text()}; if(!response.ok && response.status!==202) throw new Error(result.error);return result;
|
|
52
|
+
};
|
|
53
|
+
const key = v=>{if(typeof v!=='string'||v.length>128||!v.length)throw new Error('invalid key');return v;};
|
|
54
|
+
return Object.freeze({
|
|
55
|
+
caller:Object.freeze({...caller,roles:Object.freeze(Object.fromEntries(Object.entries(caller.roles??{}).map(([app,roles])=>[app,Object.freeze([...roles])])) )}), records, office,
|
|
56
|
+
rooms:Object.freeze({send:args=>import('./tool-events.mjs').then(m=>m.sendToolEvent(env,caller,definition,args))}),
|
|
57
|
+
shop:()=>office('/_studio/api/shop'),
|
|
58
|
+
database:Object.freeze({
|
|
59
|
+
async get(k){const r=await env.DB.prepare('SELECT value FROM tool_data WHERE namespace=?1 AND key=?2').bind(namespace,key(k)).first();return r?JSON.parse(r.value):null;},
|
|
60
|
+
async put(k,value){const text=JSON.stringify(value);if(text.length>16384)throw new Error('value too large');await env.DB.prepare('INSERT INTO tool_data(namespace,key,value) VALUES(?1,?2,?3) ON CONFLICT(namespace,key) DO UPDATE SET value=excluded.value').bind(namespace,key(k),text).run();},
|
|
61
|
+
}),
|
|
62
|
+
fetch:globalThis.fetch.bind(globalThis),
|
|
63
|
+
// A named secret may authenticate an HTTPS request; its value is never returned.
|
|
64
|
+
async fetchWithSecret(name,url,init={}) {
|
|
65
|
+
const rule=cat.studio?.mcp?.secrets?.[name]; const target=new URL(url);
|
|
66
|
+
if(!rule || target.protocol!=='https:' || target.origin!==rule.origin || (!caller.owner&&!rule.tools?.includes(definition.name))) throw new Error('secret capability refused');
|
|
67
|
+
const secret=env[name];if(typeof secret!=='string')throw new Error('secret unavailable');
|
|
68
|
+
const headers=new Headers(init.headers);headers.set(rule.header??'authorization',`${rule.prefix??'Bearer '}${secret}`);
|
|
69
|
+
const res=await fetch(target,{...init,headers,redirect:'error'});
|
|
70
|
+
// Do not return a remote echo of credentials.
|
|
71
|
+
const text=await res.text();if(text.includes(secret))throw new Error('response contained a credential');
|
|
72
|
+
return new Response([204,205,304].includes(res.status)?null:text,{status:res.status,headers:{'content-type':res.headers.get('content-type')??'text/plain'}});
|
|
73
|
+
},
|
|
74
|
+
async ai(model,input){if((!caller.owner&&!cat.studio?.mcp?.aiTools?.includes(definition.name))||!env.AI)throw new Error('Workers AI unavailable');return env.AI.run(model,input);},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
export function builtins(env,cat,origin) {
|
|
78
|
+
const tool=(name,description,audience,inputSchema,handler)=>({name,description,audience,inputSchema,handler});
|
|
79
|
+
const tools=[
|
|
80
|
+
tool('studio_info','The public studio, apps, games and posts.','public',object(),async()=>{const pub=publicCatalogue(cat,await settingsOf(env),env);return {studio:pub.studio?.name,games:pub.games.map(g=>({id:g.id,name:g.name,kind:g.kind??'game'})),posts:pub.posts};}),
|
|
81
|
+
tool('studio_office','Live rooms and the players as shown in the office.','owner',object(),(_,ctx)=>ctx.office('/_studio/api/office')),
|
|
82
|
+
tool('studio_shop','Orders, refunds and the shop state.','owner',object(),(_,ctx)=>ctx.shop()),
|
|
83
|
+
tool('studio_players','The studio accounts as shown in the office.','owner',object({query:str}),({query})=>players.list(env,{q:query})),
|
|
84
|
+
tool('studio_stats','Studio visits and plays.','owner',object(),()=>readStats(env,cat,{range:rangeOf(new URLSearchParams())})),
|
|
85
|
+
tool('studio_posts','Published posts.','public',object(),()=>({posts:cat.posts??[]})),
|
|
86
|
+
tool('studio_parts','Parts this studio has chosen to share.','public',object(),async()=>{const r=await env.ASSETS.fetch(new Request(origin+'/parts/index.json'));return r.ok?r.json():{parts:[]};}),
|
|
87
|
+
tool('app_schema','Discover an app’s record fields, valid values and the roles available to this caller before reading or changing records.','public',object({app:str},['app']),async({app},ctx)=>{
|
|
88
|
+
const meta=cat.games.find(g=>g.id===app&&g.kind==='app');
|
|
89
|
+
if(!meta||(!ctx.caller.owner&&launchOf(meta,await settingsOf(env,{fresh:true}),env)!=='public'))throw new Error('app unavailable');
|
|
90
|
+
const roles={};for(const [role,definition] of Object.entries(meta.roles??{}))if(!definition.signIn||await allowed({app,role},ctx.caller,env))roles[role]={can:definition.can,signIn:Boolean(definition.signIn)};
|
|
91
|
+
const collections=new Set(Object.values(roles).flatMap(r=>r.can.map(cap=>cap.split(':')[1])));
|
|
92
|
+
return {app,roles,collections:Object.fromEntries(Object.entries(meta.records?.collections??{}).filter(([name])=>collections.has(name)))};
|
|
93
|
+
}),
|
|
94
|
+
tool('app_records','List, read, create or update app records with the app role and record version.','public',object({app:str,role:str,collection:str,id:str,operation:{enum:['read','create','update']},data:{type:'object'},version:{type:'integer',minimum:1}},['app','collection']), (args,ctx)=>ctx.records(args)),
|
|
95
|
+
];
|
|
96
|
+
for(const [name,path,description] of [
|
|
97
|
+
['room_announce','announce','Announce to a room.'],['room_kick','kick','Ask the owner to remove a player.'],['room_mute','mute','Ask the owner to mute a player.'],['room_close','close','Ask the owner to close a room.'],['studio_game','game','Ask the owner to change game access or room size.'],['shop_refund','shop/refund','Ask the owner to refund an order.'],
|
|
98
|
+
]) tools.push(tool(name,description,'owner',object({action:{type:'object'}},['action']),({action},ctx)=>ctx.office('/_studio/api/'+path,action)));
|
|
99
|
+
const reads={rooms:'office',servers:'servers',members:'servers/members',chat:'chat',lounge:'lounge',shop:'shop',orders:'shop/orders.csv',lines:'shop/lines',received:'shop/received',statements:'shop/statements',invites:'invites'};
|
|
100
|
+
tools.push(tool('studio_office_read','Read an office view; use query for its existing filters and pagination.','owner',object({view:{enum:Object.keys(reads)},query:{type:'object',additionalProperties:{type:'string'}}},['view']),({view,query},ctx)=>ctx.office('/_studio/api/'+reads[view],undefined,query)));
|
|
101
|
+
const actions=['announce','kick','mute','close','game','servers','servers/set','servers/close','servers/member','room-level','agents/brain','chat/rules','chat/remove','chat/report','chat/budget','lounge/rules','lounge/night','lounge/mod','lounge/remove','lounge/hold','shop/release','shop/refund','shop/settle','invites','invites/revoke'];
|
|
102
|
+
tools.push(tool('studio_office_action','Use an existing office control. Destructive changes return a browser confirmation for the owner.','owner',object({operation:{enum:actions},action:{type:'object'}},['operation','action']),({operation,action},ctx)=>ctx.office('/_studio/api/'+operation,action)));
|
|
103
|
+
for (const op of ['sit','look','act','speak','stand']) tools.push(tool('agent_'+op, 'A marked AI guide: '+op+'. Room policy and declared vocabulary apply.', 'signed-in', object({game:str,role:str,server:str,room:str,goal:str,line:str,args:{type:'object'}},['game']), async (args,ctx)=>(await import('./mcp-room.mjs')).remoteSeat(env,cat,ctx.caller,op,args)));
|
|
104
|
+
return tools;
|
|
105
|
+
}
|
package/worker/mcp.mjs
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import OAuthProvider from '@cloudflare/workers-oauth-provider';
|
|
2
|
+
import { createMcpHandler } from 'agents/mcp/server';
|
|
3
|
+
import { McpServer, fromJsonSchema } from '@modelcontextprotocol/server';
|
|
4
|
+
import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/validators/cf-worker';
|
|
5
|
+
import { players } from './players.mjs';
|
|
6
|
+
import { isOwner, sameOrigin } from './office.mjs';
|
|
7
|
+
import { cookieValue } from './stats.mjs';
|
|
8
|
+
import { esc } from './site.mjs';
|
|
9
|
+
import { oauthStorage, rateLimit, audit, callsPerMinute } from './mcp-store.mjs';
|
|
10
|
+
import { allowed, currentCaller, services, builtins } from './mcp-tools.mjs';
|
|
11
|
+
const validator = new CfWorkerJsonSchemaValidator();
|
|
12
|
+
const json = (data,status=200)=>Response.json(data,{status,headers:{'cache-control':'no-store'}});
|
|
13
|
+
const html = (body,headers={})=>new Response(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Connect your AI</title><style>body{font:18px system-ui;max-width:640px;margin:64px auto;padding:24px;background:#faf9f6;color:#252520}button,a{padding:12px}button{font:inherit}li{margin:16px 0}</style><main>${body}</main></html>`,{headers:{'content-type':'text/html; charset=utf-8','cache-control':'no-store','content-security-policy':"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",...Object.fromEntries(new Headers(headers))}});
|
|
14
|
+
async function browserPerson(request,env) {
|
|
15
|
+
const person=await players.of(request,env);
|
|
16
|
+
if(person&&!person.guest)return person.id;
|
|
17
|
+
if(await isOwner(request,env)) {
|
|
18
|
+
const token=cookieValue(request,'studio_owner');
|
|
19
|
+
if(token){const hash=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(token));return 'office-'+[...new Uint8Array(hash)].map(b=>b.toString(16).padStart(2,'0')).join('');}
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
24
|
+
const url=new URL(request.url), origin=url.origin, path=url.pathname;
|
|
25
|
+
if(!env.DB)return json({error:'studio database required'},503);
|
|
26
|
+
if(request.headers.has('origin') && request.headers.get('origin')!==origin)return json({error:'origin refused'},403);
|
|
27
|
+
// Cap all protocol bodies before handing them to a library or a handler.
|
|
28
|
+
if(request.body){
|
|
29
|
+
const reader=request.body.getReader(),chunks=[];let size=0;
|
|
30
|
+
for(;;){const {done,value}=await reader.read();if(done)break;size+=value.length;if(size>65536){await reader.cancel();return json({error:'request too large'},413);}chunks.push(value);}
|
|
31
|
+
const bytes=new Uint8Array(size);let at=0;for(const chunk of chunks){bytes.set(chunk,at);at+=chunk.length;}request=new Request(request,{body:bytes});
|
|
32
|
+
}
|
|
33
|
+
if(path.startsWith('/hooks/tools/')) return (await import('./tool-webhook.mjs')).toolWebhook(request,env,{cat:await catalogueOf(),definitions});
|
|
34
|
+
const scoped={...env,OAUTH_KV:oauthStorage(env.DB)};
|
|
35
|
+
const api=async(request,env,ctx)=>{
|
|
36
|
+
let caller;
|
|
37
|
+
try{caller=await currentCaller(env,ctx.props);}catch{return new Response(JSON.stringify({error:'connection revoked'}),{status:401,headers:{'content-type':'application/json','cache-control':'no-store','www-authenticate':`Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp", error="invalid_token"`}});}
|
|
38
|
+
const cat=await catalogueOf();const tools=[...builtins(env,cat,origin),...definitions];
|
|
39
|
+
if(request.method==='POST') {
|
|
40
|
+
const body=await request.clone().json().catch(()=>null);
|
|
41
|
+
if(body?.method==='tools/call') {
|
|
42
|
+
const tool=tools.find(t=>t.name===body.params?.name);
|
|
43
|
+
if(tool&&!validator.getValidator(tool.inputSchema)(body.params?.arguments??{}).valid)await audit(env,caller,tool.name,'invalid');
|
|
44
|
+
if(!tool)await audit(env,caller,'unknown-tool','denied');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const rate=async fresh=>{
|
|
48
|
+
const limit=callsPerMinute(cat);
|
|
49
|
+
if(!await rateLimit(env,fresh.id??`public:${request.headers.get('cf-connecting-ip')??'local'}`,limit))throw new Error('Rate limit reached');
|
|
50
|
+
};
|
|
51
|
+
const read=async(name,handler)=>{
|
|
52
|
+
let outcome='error';
|
|
53
|
+
try{const fresh=await currentCaller(env,ctx.props);await rate(fresh);const result=await handler(fresh);if(JSON.stringify(result).length>65536)throw new Error('Result too large');outcome='ok';return result;}
|
|
54
|
+
finally{await audit(env,caller,name,outcome);}
|
|
55
|
+
};
|
|
56
|
+
const server=async()=>{
|
|
57
|
+
const s=new McpServer({name:cat.studio?.name??'Homie studio',version:'1.0.0'},{jsonSchemaValidator:validator});
|
|
58
|
+
for (const meta of cat.games.filter(g=>g.kind==='app' && g.records?.persist)) {
|
|
59
|
+
for (const [role,declared] of Object.entries(meta.roles??{})) {
|
|
60
|
+
if (declared.signIn && !await allowed({app:meta.id,role},caller,env)) continue;
|
|
61
|
+
for (const collection of Object.keys(meta.records.collections??{})) {
|
|
62
|
+
if (!declared.can.includes('read:'+collection)) continue;
|
|
63
|
+
const uri=`studio://records/${meta.id}/${role}/${collection}`;
|
|
64
|
+
s.registerResource(`${meta.id}_${role}_${collection}`,uri,{description:'Untrusted app record data',mimeType:'application/json'},async()=>read('resource:'+meta.id+':'+collection,async fresh=>{
|
|
65
|
+
const data=await services(env,cat,origin,fresh,'records').records({app:meta.id,role,collection});
|
|
66
|
+
return {contents:[{uri,mimeType:'application/json',text:JSON.stringify({kind:'untrusted-record-data',data})}]};
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for(const tool of tools){
|
|
72
|
+
if (tool.kind==='prompt') {
|
|
73
|
+
if (!await allowed(tool.audience,caller,env)) continue;
|
|
74
|
+
s.registerPrompt(tool.name,{description:tool.description,argsSchema:fromJsonSchema(tool.inputSchema,validator)},async(args)=>read('prompt:'+tool.name,async fresh=>{
|
|
75
|
+
if(!await allowed(tool.audience,fresh,env))throw new Error('Permission denied');
|
|
76
|
+
const text=await tool.handler(args,services(env,cat,origin,fresh,tool.namespace??tool.name,tool));
|
|
77
|
+
return {messages:[{role:'user',content:{type:'text',text:JSON.stringify({kind:'studio-prompt-data',prompt:tool.name,data:text})}}]};
|
|
78
|
+
}));continue;
|
|
79
|
+
}
|
|
80
|
+
s.registerTool(tool.name,{description:tool.description,inputSchema:fromJsonSchema(tool.inputSchema,validator)},async(args)=>{
|
|
81
|
+
let outcome='error';
|
|
82
|
+
try{
|
|
83
|
+
// Recheck inside dispatch, even when a connection listed tools earlier.
|
|
84
|
+
const fresh=await currentCaller(env,ctx.props);
|
|
85
|
+
if(!await allowed(tool.audience,fresh,env)){outcome='denied';throw new Error('Permission denied');}
|
|
86
|
+
try{await rate(fresh);}catch(error){outcome='limited';throw error;}
|
|
87
|
+
const value=await tool.handler(args,services(env,cat,origin,fresh,tool.namespace??tool.name,tool));
|
|
88
|
+
const text=JSON.stringify({kind:'untrusted-tool-data',tool:tool.name,data:value});
|
|
89
|
+
if(text.length>65536)throw new Error('Result too large; narrow your request');
|
|
90
|
+
outcome='ok';return {content:[{type:'text',text}]};
|
|
91
|
+
}catch(error){return {isError:true,content:[{type:'text',text:outcome==='denied'||outcome==='limited'?error.message:'Tool failed. Check the office audit and the tool definition.'}]};}
|
|
92
|
+
finally{await audit(env,caller,tool.name,outcome);}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return s;
|
|
96
|
+
};
|
|
97
|
+
return createMcpHandler(server)(request,env,ctx);
|
|
98
|
+
};
|
|
99
|
+
const provider=new OAuthProvider({
|
|
100
|
+
apiRoute:'/mcp',apiHandler:{fetch:api},
|
|
101
|
+
authorizeEndpoint:'/oauth/authorize',tokenEndpoint:'/oauth/token',clientRegistrationEndpoint:'/oauth/register',
|
|
102
|
+
resourceMetadata:{resource:origin+'/mcp',authorization_servers:[origin]},
|
|
103
|
+
scopesSupported:['studio','offline_access'],requiredScopes:['studio'],
|
|
104
|
+
accessTokenTTL:3600,
|
|
105
|
+
defaultHandler:{async fetch(req,env,ctx){
|
|
106
|
+
const u=new URL(req.url),person=await browserPerson(req,env),oauth=env.OAUTH_PROVIDER;
|
|
107
|
+
if(u.pathname==='/oauth/authorize'){
|
|
108
|
+
if(!person)return html(`<h1>Sign in to your studio</h1><p>Use your existing studio account, then approve your AI.</p><a href="/account?next=${esc(encodeURIComponent(u.pathname+u.search))}">Sign in</a>`,{'x-frame-options':'DENY'});
|
|
109
|
+
try{
|
|
110
|
+
if(req.method==='GET'){
|
|
111
|
+
const auth=await oauth.parseAuthRequest(req),description=await oauth.describeConsent(auth),transaction=await oauth.beginConsent(auth);
|
|
112
|
+
await env.OAUTH_KV.put('consent-person:'+transaction.handle,person,{expirationTtl:600});
|
|
113
|
+
return html(`<h1>Connect your AI</h1><p><strong>${esc(description.clientName)}</strong> asks to use this studio with your current permissions.</p><p>The app supplies this name. Approve only if you started this connection and recognize the return address.</p><p>Return address: ${esc(description.redirectHost)}. ${description.redirectIsLoopback?'This is a local app; any local process could be listening.':''}</p><p>Your AI can use your app roles. If you own the studio, it can use the office. Destructive office actions still need your confirmation. Revoke this connection in the office at any time.</p><form method="post"><input type="hidden" name="handle" value="${esc(transaction.handle)}"><button name="decision" value="allow">Allow connection</button> <button name="decision" value="deny">Cancel</button></form>`,transaction.headers);
|
|
114
|
+
}
|
|
115
|
+
if(req.method!=='POST'||!sameOrigin(req,u))return json({error:'same-origin POST required'},403);
|
|
116
|
+
const form=await req.formData(),handle=String(form.get('handle')??'');
|
|
117
|
+
if(form.get('decision')!=='allow'){const denied=await oauth.denyConsent(req,handle);return new Response(null,{status:302,headers:denied.headers});}
|
|
118
|
+
if(await env.OAUTH_KV.get('consent-person:'+handle)!==person)return json({error:'Sign-in changed; restart the connection'},403);
|
|
119
|
+
const approved=await oauth.approveConsent(req,handle,{scope:['studio','offline_access']});
|
|
120
|
+
await env.OAUTH_KV.delete('consent-person:'+handle);
|
|
121
|
+
const connection=crypto.randomUUID();
|
|
122
|
+
await env.DB.prepare('INSERT INTO mcp_connections(id,person,client,created) VALUES(?1,?2,?3,?4)').bind(connection,person,approved.request.clientId,Date.now()).run();
|
|
123
|
+
const result=await oauth.completeAuthorization({request:approved.request,userId:person,scope:['studio','offline_access'],metadata:{connection},props:{connection}});
|
|
124
|
+
approved.headers.set('location',result.redirectTo);return new Response(null,{status:302,headers:approved.headers});
|
|
125
|
+
}catch{return json({error:'Invalid or expired authorization request'},400);}
|
|
126
|
+
}
|
|
127
|
+
if(u.pathname==='/_studio/office/connections'){
|
|
128
|
+
if(!person)return html('<h1>Sign in to manage AI connections</h1><a href="/account?next=/_studio/office/connections">Sign in</a>');
|
|
129
|
+
const owner=await isOwner(req,env);
|
|
130
|
+
if(req.method==='POST'){
|
|
131
|
+
if(!sameOrigin(req,u))return json({error:'origin refused'},403);
|
|
132
|
+
const f=await req.formData();const id=String(f.get('connection')??'');
|
|
133
|
+
const connection=await env.DB.prepare('SELECT * FROM mcp_connections WHERE id=?1').bind(id).first();
|
|
134
|
+
if(connection&&(owner||connection.person===person)){
|
|
135
|
+
// Local denial takes effect first, even if upstream token cleanup must be retried.
|
|
136
|
+
await env.DB.prepare('UPDATE mcp_connections SET revoked=1 WHERE id=?1').bind(id).run();
|
|
137
|
+
let cursor;
|
|
138
|
+
do{const grants=await oauth.listUserGrants(connection.person,{limit:100,cursor});
|
|
139
|
+
for(const grant of grants.items)if(grant.metadata?.connection===id)await oauth.revokeGrant(grant.id,connection.person);
|
|
140
|
+
cursor=grants.cursor;
|
|
141
|
+
}while(cursor);
|
|
142
|
+
}
|
|
143
|
+
return Response.redirect(origin+u.pathname,303);
|
|
144
|
+
}
|
|
145
|
+
const page=Math.max(0,Math.min(100,Number.parseInt(u.searchParams.get('page')??'0',10)||0)),offset=page*100;
|
|
146
|
+
const rows=(await env.DB.prepare(owner?'SELECT c.*,p.name FROM mcp_connections c LEFT JOIN players p ON p.id=c.person ORDER BY c.created DESC LIMIT 100 OFFSET ?1':'SELECT c.*,p.name FROM mcp_connections c LEFT JOIN players p ON p.id=c.person WHERE c.person=?1 ORDER BY c.created DESC LIMIT 100 OFFSET ?2').bind(...(owner?[offset]:[person,offset])).all()).results;
|
|
147
|
+
const events=owner?(await env.DB.prepare('SELECT a.*,p.name FROM mcp_audit a LEFT JOIN players p ON p.id=a.person ORDER BY a.id DESC LIMIT 100 OFFSET ?1').bind(offset).all()).results:[];
|
|
148
|
+
const names=new Map(await Promise.all([...new Set([...rows,...events].map(r=>r.client).filter(Boolean))].map(async id=>[id,(await oauth.lookupClient(id).catch(()=>null))?.clientName??id])));
|
|
149
|
+
return html(`<h1>Your studio's AI connections</h1><p>MCP address: <code>${esc(origin)}/mcp</code></p><ul>${rows.map(r=>`<li>${esc(r.name??(r.person.startsWith('office-')?'Owner browser':r.person))} · ${esc(names.get(r.client))} · ${r.revoked?'Revoked':`<form method="post"><input type="hidden" name="connection" value="${esc(r.id)}"><button>Revoke</button></form>`}</li>`).join('')}</ul><h2>Recent tool calls</h2><ul>${events.map(r=>`<li>${esc(new Date(r.at).toISOString())} · ${esc(r.name??(r.person?.startsWith('office-')?'Owner browser':r.person)??'Public')} · ${esc(names.get(r.client)??'Anonymous')} · ${esc(r.tool)} · ${esc(r.outcome)}</li>`).join('')}</ul><nav>${page?`<a href="?page=${page-1}">Newer</a>`:''}${rows.length===100||events.length===100?`<a href="?page=${page+1}">Older</a>`:''}</nav>`);
|
|
150
|
+
}
|
|
151
|
+
return json({error:'not found'},404);
|
|
152
|
+
}},
|
|
153
|
+
});
|
|
154
|
+
if(path==='/mcp'&&!request.headers.has('authorization')){
|
|
155
|
+
// Public tools remain available without an account. Clients can discover OAuth metadata directly.
|
|
156
|
+
return api(request,scoped,ctx);
|
|
157
|
+
}
|
|
158
|
+
return provider.fetch(request,scoped,ctx);
|
|
159
|
+
}
|
package/worker/office-page.mjs
CHANGED
|
@@ -937,7 +937,7 @@ export async function officePage(cat, headers = {}) {
|
|
|
937
937
|
const name = cat.studio?.name ?? 'Studio';
|
|
938
938
|
const options = (cat.games ?? []).map((g) => `<option value="${esc(g.id)}">every room of ${esc(g.name)}</option>`).join('');
|
|
939
939
|
const body = `<header class="top"><h1>${esc(name)} <small>Office</small></h1>
|
|
940
|
-
<div class="links"><span class="live"><i class="dot" id="livedot"></i><span id="live">…</span></span><a href="/_studio/stats">Stats</a>${cat.shop ? '<a href="/_studio/office/shop">Shop</a>' : ''}
|
|
940
|
+
<div class="links"><span class="live"><i class="dot" id="livedot"></i><span id="live">…</span></span><a href="/_studio/stats">Stats</a><a href="/_studio/office/connections">AI connections</a>${cat.shop ? '<a href="/_studio/office/shop">Shop</a>' : ''}
|
|
941
941
|
<form method="post" action="/_studio/signout" style="display:inline"><button class="ghost small" type="submit">Sign out</button></form></div></header>
|
|
942
942
|
<form class="bar" id="announce-all"><label for="announce-text">Announce</label><input type="text" id="announce-text" maxlength="280" placeholder="A line every player sees, in the game" autocomplete="off">
|
|
943
943
|
<select id="announce-scope" aria-label="Who sees it"><option value="">every room of every game</option>${options}</select><button type="submit">Send</button>
|
package/worker/office.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { selling } from './extensions.mjs';
|
|
2
2
|
import { purchaseOrderIfPresent } from './shop.mjs';
|
|
3
|
+
import { toolIdentity } from './tool-identity.mjs';
|
|
3
4
|
/**
|
|
4
5
|
* THE STUDIO'S BACK OFFICE (@homie-rocks/studio 0.13.0): the studio's owner, and the owner's AI, see and run the
|
|
5
6
|
* studio's live games. Everything lives in the studio's own Worker and D1; homie.rocks stores none of it.
|
|
@@ -1196,7 +1197,7 @@ async function invitesApi(request, env, url, cat, body, who) {
|
|
|
1196
1197
|
async function api(request, env, url, cat) {
|
|
1197
1198
|
const path = url.pathname;
|
|
1198
1199
|
// An office key, the owner's signed-in browser, or the owner's own player account (a passkey).
|
|
1199
|
-
let who = await ownerAllowed(request, env, { kinds: ['office', 'session'] });
|
|
1200
|
+
let who = toolIdentity(request)?.owner ? 'office' : await ownerAllowed(request, env, { kinds: ['office', 'session'] });
|
|
1200
1201
|
if (!who && (await isOwner(request, env))) who = 'session';
|
|
1201
1202
|
if (!who) {
|
|
1202
1203
|
return json({ ok: false, error: 'owner-only', message: 'The back office is the studio owner\'s. In the studio folder, `npx --no-install homie-studio office key` gives the owner\'s AI a key; `npx --no-install homie-studio office link` signs the owner\'s browser in.' }, 401, { 'www-authenticate': 'Bearer' });
|
package/worker/purchase-mcp.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { purchaseResponse } from './purchase-response.mjs';
|
|
2
2
|
import { machineCapabilities } from './payment-capabilities.mjs';
|
|
3
3
|
/** The SDK owns protocol negotiation, JSON-RPC errors and stateless HTTP transport. */
|
|
4
|
-
import { Server } from '
|
|
5
|
-
import { WebStandardStreamableHTTPServerTransport } from '
|
|
6
|
-
import { CallToolRequestSchema, ListToolsRequestSchema } from '
|
|
4
|
+
import { Server } from 'mcp-purchase-sdk/server/index.js';
|
|
5
|
+
import { WebStandardStreamableHTTPServerTransport } from 'mcp-purchase-sdk/server/webStandardStreamableHttp.js';
|
|
6
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from 'mcp-purchase-sdk/types.js';
|
|
7
7
|
import { machineResource } from './purchase-machine.mjs';
|
|
8
8
|
export const purchaseSchema = {
|
|
9
9
|
type: 'object', required: ['kind', 'resource', 'version', 'buyer', 'claim', 'offerVersion'],
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import {CfWorkerJsonSchemaValidator} from '@modelcontextprotocol/server/validators/cf-worker';
|
|
2
|
+
import {signControl} from './office.mjs';
|
|
3
|
+
import {allowed} from './mcp-tools.mjs';
|
|
4
|
+
/** A tool must declare each external event and its schema; it cannot impersonate player input. */
|
|
5
|
+
export async function sendToolEvent(env,caller,definition,{game,room,event,data}) {
|
|
6
|
+
const declaration=definition.events?.[event];
|
|
7
|
+
if(!declaration||declaration.game!==game||!/^external:[a-z][a-z0-9_-]{0,40}$/.test(event)||!/^[A-Za-z0-9_-]{1,32}$/.test(room))throw new Error('event not declared');
|
|
8
|
+
if(!await allowed(declaration.audience??'owner',caller,env))throw new Error('event permission denied');
|
|
9
|
+
if(!new CfWorkerJsonSchemaValidator().getValidator(declaration.schema)(data).valid)throw new Error('event input invalid');
|
|
10
|
+
if(JSON.stringify(data).length>8192)throw new Error('event too large');
|
|
11
|
+
const ctl=await signControl(env,{op:'mcp-event',game,room,args:{event,data,person:caller.id}});
|
|
12
|
+
const response=await env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`)).fetch(`https://table/__mcp-event?game=${game}&room=${room}`,{method:'POST',body:JSON.stringify(ctl)});
|
|
13
|
+
const result=await response.json();if(!response.ok)throw new Error('room refused event');return result;
|
|
14
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Internal request authority. HTTP headers cannot manufacture this identity. */
|
|
2
|
+
const identities = new WeakMap();
|
|
3
|
+
export function withToolIdentity(request, identity) { identities.set(request, identity); return request; }
|
|
4
|
+
export function toolIdentity(request) { return identities.get(request) ?? null; }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {allowed,services} from './mcp-tools.mjs';
|
|
2
|
+
import {rateLimit,audit,callsPerMinute} from './mcp-store.mjs';
|
|
3
|
+
import {CfWorkerJsonSchemaValidator} from '@modelcontextprotocol/server/validators/cf-worker';
|
|
4
|
+
/** HMAC-SHA256(timestamp + '.' + delivery id + '.' + exact request body). */
|
|
5
|
+
export async function toolWebhook(request,env,{cat,definitions}) {
|
|
6
|
+
const name=new URL(request.url).pathname.split('/').pop(),tool=definitions.find(t=>t.name===name&&t.webhook);
|
|
7
|
+
const fail=(status)=>Response.json({ok:false},{status});
|
|
8
|
+
if(!tool||request.method!=='POST')return fail(404);
|
|
9
|
+
const timestamp=request.headers.get('x-studio-timestamp')??'',delivery=request.headers.get('x-studio-delivery')??'',signature=request.headers.get('x-studio-signature')??'';
|
|
10
|
+
if(!/^\d{10}$/.test(timestamp)||Math.abs(Date.now()/1000-Number(timestamp))>300||!/^[a-zA-Z0-9_-]{8,100}$/.test(delivery)||!/^[a-f0-9]{64}$/.test(signature))return fail(401);
|
|
11
|
+
const secret=env[tool.webhook.secret];if(typeof secret!=='string'||secret.length<32)return fail(503);
|
|
12
|
+
const body=await request.text();if(body.length>65536)return fail(413);
|
|
13
|
+
const key=await crypto.subtle.importKey('raw',new TextEncoder().encode(secret),{name:'HMAC',hash:'SHA-256'},false,['verify']);
|
|
14
|
+
const bytes=Uint8Array.from(signature.match(/../g),b=>parseInt(b,16));
|
|
15
|
+
if(!await crypto.subtle.verify('HMAC',key,bytes,new TextEncoder().encode(`${timestamp}.${delivery}.${body}`)))return fail(401);
|
|
16
|
+
const person=await env.DB.prepare('SELECT id,name,owner,guest FROM players WHERE id=?1').bind(tool.webhook.person).first();
|
|
17
|
+
const caller={id:person?.id,owner:person?.owner===1,client:'webhook:'+name};let outcome='denied';
|
|
18
|
+
try{
|
|
19
|
+
if(!person||person.guest||!await allowed(tool.audience,caller,env))return fail(403);
|
|
20
|
+
const limit=callsPerMinute(cat);
|
|
21
|
+
if(!await rateLimit(env,caller.client,limit)){outcome='limited';return fail(429);}
|
|
22
|
+
const value=JSON.parse(body),validate=new CfWorkerJsonSchemaValidator().getValidator(tool.inputSchema);
|
|
23
|
+
if(!validate(value).valid){outcome='invalid';return fail(400);}
|
|
24
|
+
const result=await env.DB.prepare('INSERT OR IGNORE INTO mcp_kv(key,value,expires) VALUES(?1,?2,?3)').bind(`delivery:${name}:${delivery}`,'true',Math.floor(Date.now()/1000)+600).run();
|
|
25
|
+
if(!result.meta.changes){outcome='replay';return fail(409);}
|
|
26
|
+
await tool.handler(value,services(env,cat,new URL(request.url).origin,caller,tool.namespace??tool.name,tool));outcome='ok';return Response.json({ok:true});
|
|
27
|
+
}catch{outcome='error';return fail(400);}finally{await audit(env,caller,name,outcome);}
|
|
28
|
+
}
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.40.0';
|