@homie-rocks/studio 0.38.0 → 0.39.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 CHANGED
@@ -17,6 +17,35 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.39.0] - 2026-10-09
21
+
22
+ **Plugin 0.39.0** · [#78](https://github.com/homie-rocks/homie/pull/78)
23
+
24
+ Your studio has its own MCP server. Connect your AI with one browser approval and add tools for the work your studio does.
25
+
26
+ ### Added
27
+
28
+ - Connect Claude, ChatGPT or Grok to your studio's own `/mcp` address. Use your existing studio account; there is no key to find or copy. Client setup and workspace policies still apply.
29
+ - Let your AI use the office, app records, shared parts and marked guide seats in rooms. Staff keep their own app permissions. Destructive office controls still wait for your confirmation.
30
+ - Add a typed tool with `homie-studio tool new`, test it with `tool call` and share its code as a part. Tools can also supply prompts and signed inbound integrations; readable app records are resources.
31
+ - Review AI connections and recent tool calls in the office. Revoke a connection there. Role checks, validated input and configurable call limits apply on the server.
32
+
33
+ ### Changed
34
+
35
+ - Worker builds keep MCP libraries in separate modules, loaded when that surface is used. Ordinary page traffic performs no MCP storage work.
36
+
37
+ ### Fixed
38
+
39
+ - Browser input checks wait for the final touch movement to reach the game before measuring its response, including when Chrome delays delivery. Successful browser calls clear their timeout timers.
40
+
41
+ - Clean installs on Node 24 include the MCP SDK required by the payment library, so purchase tools and selling builds work alongside the studio MCP server.
42
+ - MCP integration tests close clients after failures as well as successes. The desktop check clears request timers instead of waiting an extra minute after success. Package and plugin test files have a hard timeout.
43
+
44
+ ### Upgrade notes
45
+
46
+ - Ask your AI to upgrade and build your studio. The build writes Worker modules and the normal deploy applies the new connection and audit tables. No separate Homie service or Cloudflare resource is needed.
47
+ - Studio tools are trusted code in your Worker. Review tools received as parts before adding them. The tool context does not expose raw credentials.
48
+
20
49
  ## [0.38.0] - 2026-10-09
21
50
 
22
51
  **Plugin 0.39.0**
@@ -155,6 +155,8 @@
155
155
  * homie-studio servers new <game> "<Name>" --policy open|humans-only|hybrid|beginner [--ai <n>] [--guides <n>]
156
156
  * [--kids] [--door open|accounts|invite] [--level 1-5] [--level-max 1-5]
157
157
  * [--speech game|lines|off] [--bots fill|off] [--rooms <n>] [--listed on|off]
158
+ * homie-studio tool new <name> [--app <id>]
159
+ * homie-studio tool call <name> --input <JSON> [--url <site>] [--public]
158
160
  * homie-studio servers set <game> <server> [the same flags]
159
161
  * homie-studio servers close <game> <server> [--reopen]
160
162
  * homie-studio servers level <game> <room> <1-5> (the AI's level in one room: Rookie, Steady, Fair, Strong, Maxed)
@@ -377,7 +379,7 @@ import { standaloneDeployNotes } from '../lib/standalone.mjs';
377
379
 
378
380
  const argv = process.argv.slice(2);
379
381
  const flags = new Map();
380
- const BOOL_FLAGS = ['lan', 'off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'managed', 'live', 'manual', 'renew', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before', 'release', 'device'];
382
+ const BOOL_FLAGS = ['public', 'lan', 'off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'no-rig', 'supporter', 'managed', 'live', 'manual', 'renew', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before', 'release', 'device'];
381
383
  const positional = [];
382
384
  for (let i = 0; i < argv.length; i++) {
383
385
  const a = argv[i];
@@ -487,7 +489,15 @@ function print(result) {
487
489
  lines.push(`R2 holds about ${sizeOf(result.inR2)} of this studio's media. ${result.cost}`, ...(result.warning ? [`Warning: ${result.warning}`] : []));
488
490
  break;
489
491
  }
492
+ case 'tool new':
493
+ lines.push(result.file, result.next);
494
+ break;
495
+ case 'tool call':
496
+ case 'tool list':
497
+ lines.push(JSON.stringify(result.result, null, 2));
498
+ break;
490
499
  case 'deploy':
500
+ if (result.mcp) lines.push(`Connect your AI: ${result.mcp} (approve in your studio's browser page; no key)`);
491
501
  if (result.ci) {
492
502
  lines.push(`Live: ${result.url ?? '(Wrangler printed no address)'}${result.commit ? ` (commit ${result.commit.slice(0, 7)}${result.branch ? ` on ${result.branch}` : ''})` : ''}`, ...result.steps.map((x) => ` ${x.what}`), ...result.games.map((g) => ` ${g.id}: ${g.play}${g.hash ? ` [${deployWords(g)}]` : ''}`));
493
503
  break;
@@ -823,6 +833,7 @@ const OUTWARD_FLAGS = {
823
833
  'shop connect': ['url', 'managed', 'live', 'manual', 'renew', 'from-clipboard'], 'shop disconnect': ['url'], 'shop refund': ['url', 'reason', 'note', 'manual-transaction'], 'shop statements': ['url', 'period', 'send', 'cursor'],
824
834
  'agents pass': ['url', 'label', 'server', 'hands', 'role', 'days'], 'agents revoke': ['url'], 'agents brain': ['url', 'budget', 'remove'],
825
835
  'agents sit': ['url', 'server', 'pass', 'label', 'brain'],
836
+ 'tool new': ['app'], 'tool call': ['url', 'input', 'public'], 'tool list': ['url', 'public'],
826
837
  // With --device it adds a phone to the person's Apple team and installs on it: a flag it does not know stops it.
827
838
  'standalone run': ['for', 'device', 'site'],
828
839
  };
@@ -883,6 +894,7 @@ async function main() {
883
894
  // The starter library and the optimiser work anywhere; the rest of style and assets inside a studio (lib/art-cli.mjs).
884
895
  if (cmd === 'assets' && ['find', 'optimise', 'optimize'].includes(sub)) return assetsCommand(null, sub, positional, flags, { log });
885
896
  const root = requireStudio();
897
+ if (cmd === 'tool') return (await import('../lib/tools-cli.mjs')).toolCommand(root, sub, positional, flags, { log });
886
898
  // A trailer is the plugin's video skill; this hands the words after the id over to it (lib/trailer.mjs).
887
899
  if (cmd === 'trailer') return trailerCommand(root, sub, argv.slice(argv.indexOf(sub) + 1), { skills: flags.get('skills') ?? null, slug: flags.get('slug') ?? null });
888
900
  if (cmd === 'style') return styleCommand(root, sub, positional, flags, { log });
package/lib/build.mjs CHANGED
@@ -491,6 +491,7 @@ export function orderGames(games, order, log = () => {}) {
491
491
  export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false, types = false, beforePublish = async () => {}, longCheck = false } = {}) {
492
492
  const esbuild = await studioEsbuild(root);
493
493
  const studio = readStudio(root);
494
+ await (await import('./tools-build.mjs')).buildTools(root, esbuild);
494
495
  // The shop first (shop/SHOP.md): invalid shop settings stop the build before anything is built.
495
496
  const shop = shopForBuild(root, { log });
496
497
  const live = join(root, 'site', 'dist');
@@ -689,6 +690,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
689
690
  ...(audienceOf(studio) !== 'general' ? { audience: audienceOf(studio) } : {}),
690
691
  ...(studio.referrals === false ? { referrals: false } : {}),
691
692
  ...(lounge ? { lounge } : {}),
693
+ ...(studio.mcp ? { mcp: studio.mcp } : {}),
692
694
  build: buildInfo(root),
693
695
  },
694
696
  games: rows,
@@ -723,6 +725,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
723
725
  // site, never before: a game that did not build has left both as they were.
724
726
  await beforePublish();
725
727
  const hosted = (await rulesBuild()).writeRules(root, ruled, all.filter((g) => rulesOf.get(g.id)?.host === 'server').map((g) => g.id));
728
+ await (await import('./worker-build.mjs')).buildWorker(root, esbuild);
726
729
  // All of it is there: now, and only now, it becomes site/dist (lib/stage.mjs).
727
730
  const swapped = swapIn(dist, live);
728
731
  return {
@@ -466,7 +466,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
466
466
  const next = { ...kept, ...(cf.url && !isWorkersDev(cf.url) ? { url: cf.url } : {}), accountId, d1Id: db.uuid, r2: cf.r2 ?? null, created: [...created].sort() };
467
467
  writeStudio(root, { ...readStudio(root), cloudflare: next });
468
468
  return {
469
- ok: true, command: 'deploy', url, ...(workersDev ? { workersDev, local: LOCAL_STATE } : {}), worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
469
+ ok: true, command: 'deploy', url, mcp: url ? `${url}/mcp` : null, connectAI: url ? `${url}/_studio/office/connections` : null, ...(workersDev ? { workersDev, local: LOCAL_STATE } : {}), worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
470
470
  ...(media ? { media: { moved: media.moved ?? [], failed: media.failed ?? [], inR2: media.inR2 ?? null } } : {}),
471
471
  games: b.catalogue.map((id) => { const g = delta.games.find((x) => x.id === id); return { id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null, hash: g?.hash ?? null, change: delta.first ? 'first deploy from this computer' : g?.change ?? null }; }),
472
472
  ...(delta.removed.length ? { removed: delta.removed } : {}),
@@ -628,7 +628,7 @@ export async function ciDeploy(root, { log = () => {} } = {}) {
628
628
  const hashes = builtGames(root, games);
629
629
  for (const id of games) if (hashes[id]?.hash) step(`${id}: ${deployWords(hashes[id])}`);
630
630
  return {
631
- ok: true, command: 'deploy', ci: true, url, worker: names.worker, d1: names.d1, r2: null, steps, announced: [],
631
+ ok: true, command: 'deploy', ci: true, url, mcp: url ? `${url}/mcp` : null, connectAI: url ? `${url}/_studio/office/connections` : null, worker: names.worker, d1: names.d1, r2: null, steps, announced: [],
632
632
  commit: process.env.WORKERS_CI_COMMIT_SHA ?? null, branch: process.env.WORKERS_CI_BRANCH ?? null,
633
633
  // A CI checkout has no earlier deploy to compare with: each game's content hash only (lib/deploy-state.mjs).
634
634
  games: games.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null, hash: hashes[id]?.hash ?? null })), songs: [], videos: [],
package/lib/dev.mjs CHANGED
@@ -459,7 +459,18 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
459
459
  return;
460
460
  }
461
461
  };
462
- const timer = setInterval(() => { look().catch(() => {}); rulesLook().catch(() => {}); }, watchMs);
462
+ const { toolFiles } = await import('./tools-build.mjs');
463
+ const toolsStamp = () => toolFiles(root).map(file => `${file}:${statSync(file).mtimeMs}:${statSync(file).size}`).join('|');
464
+ let lastTools = toolsStamp();
465
+ const toolsLook = async () => {
466
+ if (rebuilding || ending) return;
467
+ const stamp = toolsStamp(); if (stamp === lastTools) return;
468
+ lastTools = stamp; rebuilding = true;
469
+ try { log('Studio tools changed: type-checking and rebuilding…'); await build(root, { log }); }
470
+ catch (error) { log(`Studio tools did not build: ${error.message}`); }
471
+ finally { rebuilding = false; }
472
+ };
473
+ const timer = setInterval(() => { look().catch(() => {}); rulesLook().catch(() => {}); toolsLook().catch(() => {}); }, watchMs);
463
474
  timer.unref?.();
464
475
 
465
476
  /** Wait for the local site, then hold it to its own address (socketOriginProblem); a mismatch ends dev. */
package/lib/mcp-tools.mjs CHANGED
@@ -782,6 +782,16 @@ export function toolDefs(ctx, avail = {}) {
782
782
  return r.ok ? ok('Node and npm are ready. AI: continue studio_scaffold.', r) : fail(r.next, r);
783
783
  },
784
784
  },
785
+ {
786
+ name: 'studio_connect_ai', title: 'Connect your AI to the studio',
787
+ description: 'Give the person this studio’s own remote MCP address and connection page. Their AI connects directly with browser approval and their existing studio account; no key or local server.',
788
+ inputSchema: { type: 'object', properties: { ...STUDIO_ARG } },
789
+ annotations: { title: 'Connect your AI', ...RO },
790
+ run: async (a) => {
791
+ const root = ctx.root(a.studio), site = siteUrl(root);
792
+ return site ? ok(`Connect your AI to ${site}/mcp. Approve with your studio account in the browser. Manage connections at ${site}/_studio/office/connections. Client setup varies; do not promise a one-click import a client does not support.`, { mcp: `${site}/mcp`, connections: `${site}/_studio/office/connections` }) : fail('This studio has no published address yet. Local tools can be tested with homie-studio dev.');
793
+ },
794
+ },
785
795
  {
786
796
  name: 'studio_domain', title: 'Set the studio domain',
787
797
  description: 'Prepare a requested custom hostname for deployment. Cloudflare creates DNS and TLS using its existing browser sign-in. The AI handles configuration; never ask for an account id, zone id or DNS value. Does not deploy or change the domain registrar.',
@@ -44,7 +44,11 @@ const GPU = [
44
44
  const TAG = 'homie-studio-check';
45
45
  const ANDROID_UA = 'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Mobile Safari/537.36';
46
46
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
47
- const T = (p, ms, v = null) => Promise.race([Promise.resolve(p).catch(() => v), sleep(ms).then(() => v)]);
47
+ const T = async (p, ms, v = null) => {
48
+ let timer;
49
+ try { return await Promise.race([Promise.resolve(p).catch(() => v), new Promise(resolve => { timer = setTimeout(() => resolve(v), ms); })]); }
50
+ finally { clearTimeout(timer); }
51
+ };
48
52
  const DIRV = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] };
49
53
  const ALL = ['owner-desk', 'owner-phone', 'owner-iphone', 'round', 'life', 'tv'];
50
54
 
@@ -328,17 +332,36 @@ async function touchDrag(h, from, dir, reach, holdMs, ready) {
328
332
  /** Timestamp real input delivery, then keep it held until four frames can judge its direction.
329
333
  * The response limit stays 600 ms; slow automation delivery and the thumb's ramp are not game latency. */
330
334
  export async function measuredPress(h, how, code, from, dir) {
335
+ // CDP points use page coordinates; DOM touch receipts use frame coordinates.
336
+ let offset = { x: 0, y: 0 };
337
+ if (how !== 'keys') {
338
+ const element = await frameOf(h)?.frameElement();
339
+ if (element) {
340
+ try { offset = await element.boundingBox() ?? offset; }
341
+ finally { await element.dispose(); }
342
+ }
343
+ }
331
344
  await inFrame(h, `(() => {
332
345
  if (!window.__homieCheckInput) {
333
346
  const state = window.__homieCheckInput = {};
334
347
  addEventListener('keydown', e => { if (state.code === e.code && state.key === null) state.key = performance.now(); }, true);
335
- addEventListener('touchmove', () => { state.touch = performance.now(); }, { capture: true, passive: true });
348
+ addEventListener('touchmove', e => {
349
+ const touch = e.touches[0];
350
+ if (state.touch === null && touch && touch.clientX === state.touchX && touch.clientY === state.touchY) state.touch = performance.now();
351
+ }, { capture: true, passive: true });
336
352
  }
337
- Object.assign(window.__homieCheckInput, { code: ${JSON.stringify(code)}, key: null, touch: null });
353
+ Object.assign(window.__homieCheckInput, { code: ${JSON.stringify(code)}, key: null, touch: null, touchX: ${Math.round(from[0] + DIRV[dir][0] * 70) - offset.x}, touchY: ${Math.round(from[1] + DIRV[dir][1] * 70) - offset.y} });
338
354
  })()`);
339
355
  let a = null, b = null;
340
356
  const ready = async () => {
341
- a = await inFrame(h, `window.__homieCheckInput.${how === 'keys' ? 'key' : 'touch'}`);
357
+ // CDP acknowledgement is not DOM delivery. Wait for the final ramp point,
358
+ // then latch that receipt so later/coalesced moves cannot change it.
359
+ const deliveryDeadline = Date.now() + 3000;
360
+ do {
361
+ a = await inFrame(h, `window.__homieCheckInput.${how === 'keys' ? 'key' : 'touch'}`);
362
+ if (Number.isFinite(a)) break;
363
+ await sleep(20);
364
+ } while (Date.now() < deliveryDeadline);
342
365
  if (!Number.isFinite(a)) throw new Error('the game frame did not receive the measured input');
343
366
  await sleep(420);
344
367
  const until = Date.now() + 3000;
package/lib/scaffold.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { MCP_MIGRATION, MCP_MIGRATION_FILE } from '../worker/mcp-store.mjs';
1
2
  import { APPS_MIGRATION, APPS_MIGRATION_FILE } from '../worker/app-records.mjs';
2
3
  import { PURCHASE_MIGRATION, PURCHASE_MIGRATION_FILE, PURCHASE_STATE, PURCHASE_STATE_FILE } from '../worker/purchase-schema.mjs';
3
4
  /**
@@ -67,8 +68,8 @@ export function slugify(name) {
67
68
  * the binding names and keep it linked.
68
69
  */
69
70
  export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', ai = false, routes = null, paidParts = false, partsRateLimit = null, triggers = null }) {
70
- const at = layout === 'site' ? { schema: '../node_modules', main: 'src/worker.mjs', dist: './dist', migrations: 'migrations' }
71
- : { schema: 'node_modules', main: 'site/src/worker.mjs', dist: './site/dist', migrations: 'site/migrations' };
71
+ const at = layout === 'site' ? { schema: '../node_modules', main: 'src/runtime/worker.js', dist: './dist', migrations: 'migrations' }
72
+ : { schema: 'node_modules', main: 'site/src/runtime/worker.js', dist: './site/dist', migrations: 'site/migrations' };
72
73
  const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }];
73
74
  const config = {
74
75
  $schema: `${at.schema}/wrangler/config-schema.json`,
@@ -80,7 +81,8 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layou
80
81
  // subdomain) reaches it instead of failing with 1042.
81
82
  // `disallow_eval_during_startup`: no code is made from a string while the Worker loads (Cloudflare never allows it
82
83
  // later). A game's rules run in this Worker (NETPLAY.md section 29), and nothing in them is ever made from text.
83
- compatibility_flags: ['global_fetch_strictly_public', 'disallow_eval_during_startup', ...(paidParts ? ['nodejs_compat'] : [])],
84
+ no_bundle: true, find_additional_modules: true, rules: [{ type: 'ESModule', globs: ['**/*.js'], fallthrough: true }],
85
+ compatibility_flags: ['nodejs_compat', 'global_fetch_strictly_public', 'disallow_eval_during_startup'],
84
86
  ...(paidParts ? { alias: { '@homie-rocks/studio/worker': '@homie-rocks/studio/worker/selling' }, triggers: { crons: ['*/5 * * * *'] }, ratelimits: [partsRateLimit ?? { name: 'PURCHASE_RATE_LIMITER', namespace_id: '1001', simple: { limit: 60, period: 60 } }] } : {}),
85
87
  workers_dev: true,
86
88
  preview_urls: true,
@@ -152,6 +154,8 @@ An app is ONE screen that morphs with context: camera moves, panels unfold, its
152
154
 
153
155
  Declare roles, surfaces, words and lasting record collections in app.json. Netplay's host is not a staff permission. Use \`@homie-rocks/studio/apps\` for authorized lasting records and \`@homie-rocks/studio/links\` for ticket links, QR and HTTP-safe IDs. Staff use private role links plus existing account grants; keep private records out of public collections. Look for engine mechanisms and shared parts before writing a component. Credit licences; sharing is optional and separate from selling.
154
156
 
157
+ Your studio also serves MCP at \`/mcp\`. Staff connect their own AI with their existing account and one browser approval; never ask them for a key. Add business actions with \`homie-studio tool new <name> --app <id>\`, keep their declared role narrow, and reuse the app's existing record IDs, field formats and versions. Test owner, staff and public calls locally. Read \`node_modules/@homie-rocks/studio/tools/TOOLS.md\` and the Homie plugin's \`tools\` skill.
158
+
155
159
  Build normally, run \`homie-studio dev --lan\`, then \`homie-studio check <id> --url <origin>\`: the app check proves an actual action across a wall and two phones plus reconnect. The public screen is \`/<id>/open\`, the wall \`/<id>/tv\`. Customer apps use the same \`standalone\` build for desktop/iOS/Android; its existing sign-in and store limitations still apply. See \`node_modules/@homie-rocks/studio/apps/APPS.md\`.
156
160
 
157
161
  ## Commands (all through the pinned CLI in node_modules)
@@ -645,6 +649,8 @@ anything here wins. The whole list is in \`node_modules/@homie-rocks/studio/site
645
649
  `;
646
650
 
647
651
  const GITIGNORE = `node_modules/
652
+ site/src/runtime/
653
+ site/src/tools/
648
654
  # This computer's own state: the site's workers.dev address (it names the Cloudflare account, often after its owner).
649
655
  .studio/
650
656
  # This person's own Claude Code settings for this studio (the status line, for one).
@@ -734,11 +740,12 @@ export function studioFiles({ name, slug, homie, template = false }) {
734
740
  'site/README.md': SITE_README,
735
741
  'site/src/worker.mjs': `// This studio's site: pages, public rooms (Table + Lobby Durable Objects), D1, and R2 once storage is added.
736
742
  // The code is @homie-rocks/studio's, pinned in package.json, so an update never changes a published game by surprise.
737
- import { hostRules } from '@homie-rocks/studio/worker';
743
+ import { hostRules, useTools } from '@homie-rocks/studio/worker';
738
744
  // The rules of this studio's games that run on the server (written by homie-studio build; empty until a game has some).
739
745
  import rules from './rules/index.mjs';
740
746
 
741
747
  hostRules(rules);
748
+ useTools(async () => (await import('./tools/index.mjs')).default);
742
749
  export { default, Table, Lobby } from '@homie-rocks/studio/worker';
743
750
  `,
744
751
  'site/src/rules/index.mjs': rulesIndex([]),
@@ -755,6 +762,7 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
755
762
  [`site/migrations/${SHOP_STATEMENTS_FILE}`]: SHOP_STATEMENTS,
756
763
  [`site/migrations/${SHOP_LINES_FILE}`]: SHOP_LINES,
757
764
  [`site/migrations/${APPS_MIGRATION_FILE}`]: APPS_MIGRATION,
765
+ [`site/migrations/${MCP_MIGRATION_FILE}`]: MCP_MIGRATION,
758
766
  [`site/migrations/${LOUNGE_MIGRATION_FILE}`]: LOUNGE_MIGRATION,
759
767
  'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
760
768
  '.claude/skills/.gitkeep': '',
@@ -926,13 +934,18 @@ function ensurePartsStateMigration(root) {
926
934
  return `site/migrations/${PURCHASE_STATE_FILE}`;
927
935
  }
928
936
 
937
+ function ensureMcpMigration(root) {
938
+ const file=join(root,'site','migrations',MCP_MIGRATION_FILE);
939
+ if(existsSync(file))return null;
940
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,MCP_MIGRATION);return `site/migrations/${MCP_MIGRATION_FILE}`;
941
+ }
929
942
  export function ensureMigrations(root) {
930
- return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root)].filter(Boolean);
943
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root)].filter(Boolean);
931
944
  }
932
945
 
933
946
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
934
947
  export function migrationWord(file) {
935
- return /studio_apps/.test(file) ? 'apps: lasting records and signed-in role grants' : /lounge/.test(file) ? 'the Lounge and kept chat: play nights, moderators, and what was said only where an owner turns history on' : /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (paid sales wait until shop.json and the studio\'s Stripe connection are ready)' : /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
948
+ return /studio_mcp/.test(file) ? 'AI connections and tool call history' : /studio_apps/.test(file) ? 'apps: lasting records and signed-in role grants' : /lounge/.test(file) ? 'the Lounge and kept chat: play nights, moderators, and what was said only where an owner turns history on' : /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (paid sales wait until shop.json and the studio\'s Stripe connection are ready)' : /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
936
949
  }
937
950
 
938
951
  /**
package/lib/studio.mjs CHANGED
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url';
8
8
 
9
9
  export const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
10
10
  export const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
11
- const RESERVED_IDS = new Set(['api', 'media', 'games', 'apps', 'assets', '_homie', '_site', '_studio', 'well-known', 'index', 'play', 'studio', 'studios', 'music', 'videos', 'posts', 'rooms', 'feed', 'account']);
11
+ const RESERVED_IDS = new Set(['mcp', 'oauth', 'hooks', 'api', 'media', 'games', 'apps', 'assets', '_homie', '_site', '_studio', 'well-known', 'index', 'play', 'studio', 'studios', 'music', 'videos', 'posts', 'rooms', 'feed', 'account']);
12
12
 
13
13
  export function findStudio(from = process.cwd()) {
14
14
  let at = resolve(from);
@@ -2156,6 +2156,47 @@
2156
2156
  }
2157
2157
  }
2158
2158
  }
2159
+ },
2160
+ "0.39.0": {
2161
+ "files": {
2162
+ "CLAUDE.md": "ddf2b2a0376202f6",
2163
+ "README.md": "684b3cca8001673c",
2164
+ "HANDOFF.md": "29337e2ace95b2e7",
2165
+ "games/README.md": "5ad32fe80c61f117",
2166
+ "music/README.md": "a3dc042dadd39df8",
2167
+ "videos/README.md": "16babc3437d5545b",
2168
+ "posts/README.md": "37841aae476bd540",
2169
+ "site/README.md": "732894273a32dc3f",
2170
+ "site/src/worker.mjs": "34444a6ca4b14437"
2171
+ },
2172
+ "sections": {
2173
+ "AGENTS.md": {
2174
+ "(intro)": "214aa46eb3a895d5",
2175
+ "## Layout": "c18362ed28975d81",
2176
+ "## Apps: one screen, roles and parts": "be6f8fa45f93211a",
2177
+ "## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
2178
+ "## The site": "0cb8dd9a6f8a0096",
2179
+ "## Making games": "2da7fa74c8884834",
2180
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2181
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2182
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2183
+ "## Servers and AI seats": "98d08705087a33ec",
2184
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2185
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2186
+ "## Rules": "2e6b29afb85dcf7e",
2187
+ "## Telling Homie": "80b46fdd9e578db8",
2188
+ "## Beta": "9d02638207a26e73"
2189
+ }
2190
+ },
2191
+ "named": {
2192
+ "Homie Arcade": {
2193
+ "sections": {
2194
+ "AGENTS.md": {
2195
+ "## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
2196
+ }
2197
+ }
2198
+ }
2199
+ }
2159
2200
  }
2160
2201
  }
2161
2202
  }
@@ -0,0 +1,55 @@
1
+ import { existsSync, readdirSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { createRequire } from 'node:module';
5
+ import { partsPlugin, resolvePartImport } from './parts-build.mjs';
6
+ const require=createRequire(import.meta.url);
7
+ export function toolFiles(root) {
8
+ const dirs=[join(root,'tools')];
9
+ const apps=join(root,'apps');if(existsSync(apps))for(const app of readdirSync(apps,{withFileTypes:true}))if(app.isDirectory())dirs.push(join(apps,app.name,'tools'));
10
+ return dirs.flatMap(dir=>existsSync(dir)?readdirSync(dir).filter(f=>/^[a-z][a-z0-9-]*\.ts$/.test(f)).sort().map(f=>join(dir,f)):[]);
11
+ }
12
+ export async function buildTools(root,esbuild) {
13
+ const files=toolFiles(root),dir=join(root,'site/src/tools');
14
+ mkdirSync(dir,{recursive:true});
15
+ if(files.length){
16
+ const ts=require('typescript');
17
+ const options={target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.ESNext,moduleResolution:ts.ModuleResolutionKind.Bundler,strict:true,noEmit:true,skipLibCheck:true,allowImportingTsExtensions:true};
18
+ const host=ts.createCompilerHost(options);
19
+ host.resolveModuleNames=(names,containing)=>names.map(name=>{
20
+ if(name.startsWith('@parts/')){const part=resolvePartImport(root,name);if(part.path)return {resolvedFileName:part.path,extension:part.path.endsWith('.ts')?ts.Extension.Ts:ts.Extension.Js};}
21
+ return ts.resolveModuleName(name,containing,options,host).resolvedModule;
22
+ });
23
+ const program=ts.createProgram(files,options,host);
24
+ const errors=ts.getPreEmitDiagnostics(program);
25
+ if(errors.length)throw new Error(ts.formatDiagnosticsWithColorAndContext(errors,{getCurrentDirectory:()=>root,getCanonicalFileName:f=>f,getNewLine:()=> '\n'}));
26
+ }
27
+ const contents=files.map((f,i)=>`import t${i} from ${JSON.stringify(f)};`).join('\n')+`\nexport default [${files.map((_,i)=>'t'+i).join(',')}];\n`;
28
+ const result=await esbuild.build({stdin:{contents,resolveDir:root},bundle:true,format:'esm',platform:'neutral',target:'es2022',write:false,plugins:[partsPlugin(root)],logLevel:'silent'});
29
+ const temp=join(dir,'index.mjs');writeFileSync(temp,result.outputFiles[0].text);
30
+ // Trusted studio code, as with the existing build command. No Worker bindings exist during build.
31
+ const defs=(await import(pathToFileURL(temp).href+'?build='+Date.now())).default;
32
+ const { CfWorkerJsonSchemaValidator }=await import('@modelcontextprotocol/server/validators/cf-worker');
33
+ const validator=new CfWorkerJsonSchemaValidator(),seen=new Set();
34
+ for(const t of defs){
35
+ if(!/^[a-z][a-z0-9_]{0,63}$/.test(t.name)||/^(studio_|app_|agent_|room_|shop_)/.test(t.name)||seen.has(t.name))throw new Error(`tool name is invalid, reserved or duplicated: ${t.name}`);
36
+ seen.add(t.name);
37
+ if(typeof t.description!=='string'||!t.description.trim()||typeof t.handler!=='function')throw new Error(`tool ${t.name} needs description and handler`);
38
+ if(!['owner','signed-in','public'].includes(t.audience)&&!(t.audience&&typeof t.audience.app==='string'&&typeof t.audience.role==='string'))throw new Error(`tool ${t.name} needs an audience`);
39
+ if(t.inputSchema?.type!=='object')throw new Error(`tool ${t.name} input schema must be an object`);
40
+ validator.getValidator(t.inputSchema);
41
+ if(t.kind!==undefined&&!['tool','prompt'].includes(t.kind))throw new Error(`tool ${t.name}: invalid kind`);
42
+ if(t.webhook&&(!/^[A-Z][A-Z0-9_]{0,63}$/.test(t.webhook.secret)||typeof t.webhook.person!=='string'||!t.webhook.person))throw new Error(`tool ${t.name}: webhook needs a named secret and account`);
43
+ for(const [name,event] of Object.entries(t.events??{})){
44
+ if(!/^external:[a-z][a-z0-9_-]{0,40}$/.test(name)||!event.game||!event.schema)throw new Error(`tool ${t.name}: invalid external event`);
45
+ validator.getValidator(event.schema);
46
+ }
47
+ }
48
+ // Existing studios retain their Worker customization; install the small registry hook once.
49
+ const worker=join(root,'site/src/worker.mjs');
50
+ if(existsSync(worker)){
51
+ let text=readFileSync(worker,'utf8');
52
+ if(!text.includes('useTools(')){text += "\nimport { useTools } from '@homie-rocks/studio/worker';\nuseTools(async () => (await import('./tools/index.mjs')).default);\n";writeFileSync(worker,text);}
53
+ }
54
+ return defs.map(t=>t.name);
55
+ }
@@ -0,0 +1,55 @@
1
+ import {existsSync,mkdirSync,writeFileSync,readFileSync,chmodSync} from 'node:fs';
2
+ import {join,dirname} from 'node:path';
3
+ import {createServer} from 'node:http';
4
+ import {randomUUID} from 'node:crypto';
5
+ import {spawn} from 'node:child_process';
6
+ export function newTool(root,name,{app=null}={}) {
7
+ if(!/^[a-z][a-z0-9-]{0,39}$/.test(name??'') || app!==null&&!/^[a-z][a-z0-9-]{0,39}$/.test(app))throw new Error('name uses lowercase letters, numbers and hyphens');
8
+ if(app&&!existsSync(join(root,'apps',app,'app.json')))throw new Error('unknown app');
9
+ const file=join(root,...(app?['apps',app,'tools']:['tools']),name+'.ts');if(existsSync(file))throw new Error('tool already exists');
10
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,`import { defineTool } from '@homie-rocks/studio/tools';\n\nexport default defineTool<{ message: string }>({\n name: '${name.replaceAll('-','_')}',\n description: 'Describe what this tool does and when to use it.',\n audience: ${app?JSON.stringify({app,role:'staff'}):"'owner'"},\n inputSchema: { type: 'object', properties: { message: { type: 'string', maxLength: 200 } }, required: ['message'], additionalProperties: false },\n async handler({ message }, context) {\n return { message, by: context.caller.id };\n },\n});\n`);
11
+ return {ok:true,command:'tool new',file,next:'Edit the definition, then homie-studio dev. Call it with homie-studio tool call '+name.replaceAll('-','_')+' --input \'{"message":"Hello"}\' --url http://localhost:8787'};
12
+ }
13
+ /** The official SDK owns discovery, registration, PKCE, refresh and issuer checks. */
14
+ export async function connectToolClient(root,site,{publicOnly=false,open=defaultOpen,log=()=>{}}={}) {
15
+ const {Client,StreamableHTTPClientTransport,auth}=await import('mcp-studio-client');
16
+ const url=new URL('/mcp',site);if(url.protocol!=='https:'&&!['localhost','127.0.0.1','[::1]'].includes(url.hostname))throw new Error('Use HTTPS, or localhost for local development');
17
+ const client=new Client({name:'Homie studio tool CLI',version:'1.0.0'});
18
+ if(publicOnly){await client.connect(new StreamableHTTPClientTransport(url));return client;}
19
+ const file=join(root,'.studio','mcp',Buffer.from(url.origin).toString('base64url')+'.json');
20
+ let state={};try{state=JSON.parse(readFileSync(file,'utf8'));}catch{}
21
+ // Credentials without an issuer predate the SDK's OAuth binding fix; reconnect safely.
22
+ if((state.tokens&&!state.tokens.issuer)||(state.client&&!state.client.issuer))state={};
23
+ const save=()=>{mkdirSync(dirname(file),{recursive:true});writeFileSync(file,JSON.stringify(state),{mode:0o600});chmodSync(file,0o600);};
24
+ let accept;const returned=new Promise(resolve=>{accept=resolve;});
25
+ const csrf=randomUUID();
26
+ const server=createServer((req,res)=>{const back=new URL(req.url,'http://localhost');if(back.pathname!=='/callback'||back.searchParams.get('state')!==csrf){res.writeHead(400);res.end('Invalid callback');return;}res.end('Your AI is connected. You can close this tab.');accept(back);});
27
+ await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
28
+ const redirect=`http://127.0.0.1:${server.address().port}/callback`;
29
+ const provider={redirectUrl:redirect,clientMetadata:{client_name:'Homie studio tool CLI',redirect_uris:[redirect],grant_types:['authorization_code','refresh_token'],response_types:['code'],token_endpoint_auth_method:'none'},state:()=>csrf,
30
+ clientInformation:()=>state.client,saveClientInformation:v=>{state.client=v;save();},tokens:()=>state.tokens,saveTokens:v=>{state.tokens=v;save();},
31
+ codeVerifier:()=>state.verifier,saveCodeVerifier:v=>{state.verifier=v;save();},
32
+ discoveryState:()=>state.discovery,saveDiscoveryState:v=>{state.discovery=v;save();},
33
+ invalidateCredentials:scope=>{if(scope==='all')state={};else {delete state[scope==='tokens'?'tokens':scope];if(scope==='tokens')delete state.client;}save();},
34
+ redirectToAuthorization:async u=>{log('Approve this connection in your browser.');await open(u.href);},
35
+ };
36
+ try{
37
+ // A new loopback port needs a matching registered redirect; refresh still uses the prior client.
38
+ if(!state.tokens){delete state.client;delete state.discovery;}
39
+ const result=await auth(provider,{serverUrl:url,scope:'studio offline_access'});
40
+ if(result==='REDIRECT'){
41
+ let timer;const timeout=new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Error('Browser approval timed out')),300000);});
42
+ const back=await Promise.race([returned,timeout]).finally(()=>clearTimeout(timer));
43
+ if(back.searchParams.has('error'))throw new Error('Connection declined');
44
+ await auth(provider,{serverUrl:url,authorizationCode:back.searchParams.get('code'),iss:back.searchParams.get('iss')??undefined});
45
+ }
46
+ await client.connect(new StreamableHTTPClientTransport(url,{authProvider:provider}));return client;
47
+ }finally{server.close();}
48
+ }
49
+ function defaultOpen(url){const cmd=process.platform==='darwin'?'open':process.platform==='win32'?'explorer':'xdg-open';const child=spawn(cmd,[url],{stdio:'ignore',detached:true});child.unref();}
50
+ export async function toolCommand(root,sub,positional,flags,{log=()=>{}}={}) {
51
+ if(sub==='new')return newTool(root,positional[2],{app:flags.get('app')??null});
52
+ if(!['call','list'].includes(sub))throw new Error('Use tool new <name>, tool list, or tool call <name> --input <JSON>');
53
+ const client=await connectToolClient(root,flags.get('url')??'http://localhost:8787',{publicOnly:flags.has('public'),log});
54
+ try{const result=sub==='list'?await client.listTools():await client.callTool({name:positional[2],arguments:JSON.parse(flags.get('input')??'{}')});return {ok:result.isError!==true,command:'tool '+sub,result,...(result.isError?{why:'The tool refused or failed; see its result.'}:{})};}finally{await client.close();}
55
+ }
@@ -0,0 +1,35 @@
1
+ import {existsSync,mkdirSync,readFileSync,writeFileSync,rmSync} from 'node:fs';
2
+ import {join,relative,dirname} from 'node:path';
3
+ import {readConfig} from './routes.mjs';
4
+ import {paidReleases} from './parts-upload.mjs';
5
+ import {configPath} from './studio.mjs';
6
+ /** Preserve dynamic imports as real Worker modules: page startup never evaluates MCP libraries. */
7
+ export async function buildWorker(root,esbuild) {
8
+ const source=join(root,'site/src/worker.mjs');if(!existsSync(source))return;
9
+ const out=join(root,'site/src/runtime');
10
+ const alias={...(readConfig(root)?.alias??{})};
11
+ if(paidReleases(root).length)alias['@homie-rocks/studio/worker']='@homie-rocks/studio/worker/selling';
12
+ const result=await esbuild.build({entryPoints:[source],absWorkingDir:root,alias,outdir:out,bundle:true,splitting:true,format:'esm',platform:'browser',mainFields:['module','main'],conditions:['workerd','worker','browser'],external:['node:*','cloudflare:*'],target:'es2022',write:false,minify:true,metafile:true,logLevel:'silent'});
13
+ rmSync(out,{recursive:true,force:true});
14
+ mkdirSync(out,{recursive:true});
15
+ for(const file of result.outputFiles)writeFileSync(file.path,file.contents);
16
+ const config=configPath(root);
17
+ if(existsSync(config)){
18
+ const original=readFileSync(config,'utf8');
19
+ const json=JSON.parse(original.replace(/^\s*\/\/.*$/gm,''));
20
+ const before=JSON.stringify(json);
21
+ json.main=relative(dirname(config),join(out,'worker.js')).split('\\').join('/');
22
+ json.no_bundle=true;json.find_additional_modules=true;
23
+ json.rules=[...(json.rules??[]).filter(r=>!r.homieMcp),{type:'ESModule',globs:['**/*.js'],fallthrough:true}];
24
+ // No duplicate rule on rebuild.
25
+ json.rules=json.rules.filter((r,i,all)=>all.findIndex(x=>JSON.stringify(x)===JSON.stringify(r))===i);
26
+ json.compatibility_flags=[...new Set([...(json.compatibility_flags??[]),'nodejs_compat'])];
27
+ if(JSON.stringify(json)!==before) {
28
+ const comments=original.match(/^(?:\s*\/\/[^\n]*\n)*/)?.[0]??'';
29
+ writeFileSync(config,comments+JSON.stringify(json,null,2)+'\n');
30
+ }
31
+ }
32
+ const sizes=Object.entries(result.metafile.outputs).map(([path,value])=>({path:relative(root,path),bytes:value.bytes,imports:value.imports}));
33
+ writeFileSync(join(out,'modules.json'),JSON.stringify(sizes,null,2)+'\n');
34
+ return sizes;
35
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.38.0",
3
+ "version": "0.39.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -84,7 +84,11 @@
84
84
  "types": "./apps/apps.ts",
85
85
  "default": "./apps/apps.ts"
86
86
  },
87
- "./worker/selling": "./worker/selling/index.mjs"
87
+ "./worker/selling": "./worker/selling/index.mjs",
88
+ "./tools": {
89
+ "types": "./tools/tools.ts",
90
+ "default": "./tools/tools.ts"
91
+ }
88
92
  },
89
93
  "files": [
90
94
  "bin/",
@@ -111,7 +115,8 @@
111
115
  "NOTICE",
112
116
  "links/",
113
117
  "apps/",
114
- "app-starters/"
118
+ "app-starters/",
119
+ "tools/"
115
120
  ],
116
121
  "engines": {
117
122
  "node": ">=22"
@@ -122,17 +127,22 @@
122
127
  "@babel/parser": "8.0.7",
123
128
  "@babel/traverse": "8.0.7",
124
129
  "@babel/types": "8.0.6",
130
+ "@cloudflare/workers-oauth-provider": "1.2.3",
125
131
  "@coinbase/x402": "2.1.0",
126
132
  "@gltf-transform/core": "4.5.1",
127
133
  "@gltf-transform/extensions": "4.5.1",
128
134
  "@gltf-transform/functions": "4.5.1",
129
135
  "@jridgewell/trace-mapping": "0.3.31",
130
- "@modelcontextprotocol/sdk": "1.32.1",
136
+ "@modelcontextprotocol/sdk": "1.30.0",
137
+ "@modelcontextprotocol/server": "2.0.0",
131
138
  "@x402/core": "2.28.0",
132
139
  "@x402/evm": "2.28.0",
140
+ "agents": "0.28.0",
133
141
  "esbuild": "0.28.1",
134
142
  "gltf-validator": "2.0.0-dev.3.10",
135
143
  "jose": "6.2.12",
144
+ "mcp-purchase-sdk": "npm:@modelcontextprotocol/sdk@1.32.1",
145
+ "mcp-studio-client": "npm:@modelcontextprotocol/client@2.3.1",
136
146
  "meshoptimizer": "1.3.0",
137
147
  "mppx": "0.13.2",
138
148
  "puppeteer-core": "25.8.0",
@@ -140,10 +150,11 @@
140
150
  "spdx-expression-parse": "5.0.0",
141
151
  "stripe": "23.0.0",
142
152
  "three": "0.185.1",
153
+ "typescript": "5.9.3",
143
154
  "viem": "2.57.4",
144
- "typescript": "5.9.3"
155
+ "zod": "4.3.6"
145
156
  },
146
157
  "scripts": {
147
- "test": "node --test test/*.test.mjs"
158
+ "test": "node --test --test-concurrency=2 --test-timeout=1800000 test/*.test.mjs"
148
159
  }
149
160
  }