@treenity/mods 3.0.1 → 3.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/agent/client.ts +2 -0
  2. package/agent/guardian.ts +492 -0
  3. package/agent/seed.ts +74 -0
  4. package/agent/server.ts +4 -0
  5. package/agent/service.ts +644 -0
  6. package/agent/types.ts +184 -0
  7. package/agent/view.tsx +431 -0
  8. package/board/view.tsx +1 -1
  9. package/brahman/helpers.ts +7 -7
  10. package/brahman/service.ts +24 -24
  11. package/brahman/types.ts +21 -21
  12. package/brahman/views/action-cards.tsx +33 -23
  13. package/brahman/views/bot-view.tsx +3 -2
  14. package/brahman/views/chat-editor.tsx +119 -124
  15. package/brahman/views/menu-editor.tsx +75 -89
  16. package/brahman/views/page-layout.tsx +10 -8
  17. package/brahman/views/tstring-input.tsx +25 -15
  18. package/canary/service.ts +18 -18
  19. package/dist/board/view.js +1 -1
  20. package/dist/board/view.js.map +1 -1
  21. package/dist/brahman/helpers.d.ts +1 -1
  22. package/dist/brahman/helpers.d.ts.map +1 -1
  23. package/dist/brahman/helpers.js +6 -6
  24. package/dist/brahman/helpers.js.map +1 -1
  25. package/dist/brahman/service.js +24 -24
  26. package/dist/brahman/service.js.map +1 -1
  27. package/dist/brahman/types.d.ts +1 -1
  28. package/dist/brahman/types.d.ts.map +1 -1
  29. package/dist/brahman/types.js +21 -21
  30. package/dist/brahman/types.js.map +1 -1
  31. package/dist/brahman/views/action-cards.d.ts.map +1 -1
  32. package/dist/brahman/views/action-cards.js +7 -4
  33. package/dist/brahman/views/action-cards.js.map +1 -1
  34. package/dist/brahman/views/bot-view.d.ts.map +1 -1
  35. package/dist/brahman/views/bot-view.js +2 -1
  36. package/dist/brahman/views/bot-view.js.map +1 -1
  37. package/dist/brahman/views/chat-editor.d.ts.map +1 -1
  38. package/dist/brahman/views/chat-editor.js +27 -18
  39. package/dist/brahman/views/chat-editor.js.map +1 -1
  40. package/dist/brahman/views/menu-editor.d.ts.map +1 -1
  41. package/dist/brahman/views/menu-editor.js +12 -16
  42. package/dist/brahman/views/menu-editor.js.map +1 -1
  43. package/dist/brahman/views/page-layout.d.ts.map +1 -1
  44. package/dist/brahman/views/page-layout.js +1 -1
  45. package/dist/brahman/views/page-layout.js.map +1 -1
  46. package/dist/brahman/views/tstring-input.d.ts.map +1 -1
  47. package/dist/brahman/views/tstring-input.js +7 -3
  48. package/dist/brahman/views/tstring-input.js.map +1 -1
  49. package/dist/canary/service.js +18 -18
  50. package/dist/canary/service.js.map +1 -1
  51. package/dist/doc/fs-codec.js +1 -1
  52. package/dist/doc/fs-codec.js.map +1 -1
  53. package/dist/doc/renderers.d.ts.map +1 -1
  54. package/dist/doc/renderers.js +2 -1
  55. package/dist/doc/renderers.js.map +1 -1
  56. package/dist/doc/toolbar.d.ts.map +1 -1
  57. package/dist/doc/toolbar.js +5 -5
  58. package/dist/doc/toolbar.js.map +1 -1
  59. package/dist/launcher/types.js +2 -2
  60. package/dist/launcher/types.js.map +1 -1
  61. package/dist/launcher/view.js +2 -2
  62. package/dist/launcher/view.js.map +1 -1
  63. package/dist/mindmap/branch.d.ts +10 -0
  64. package/dist/mindmap/branch.d.ts.map +1 -1
  65. package/dist/mindmap/branch.js +42 -9
  66. package/dist/mindmap/branch.js.map +1 -1
  67. package/dist/mindmap/sidebar.d.ts.map +1 -1
  68. package/dist/mindmap/sidebar.js +4 -3
  69. package/dist/mindmap/sidebar.js.map +1 -1
  70. package/dist/mindmap/view.d.ts.map +1 -1
  71. package/dist/mindmap/view.js +35 -4
  72. package/dist/mindmap/view.js.map +1 -1
  73. package/dist/sensor-demo/service.js +6 -5
  74. package/dist/sensor-demo/service.js.map +1 -1
  75. package/dist/sensor-generator/action.js +1 -1
  76. package/dist/sensor-generator/action.js.map +1 -1
  77. package/dist/sim/service.js +41 -41
  78. package/dist/sim/service.js.map +1 -1
  79. package/dist/table/view.js.map +1 -1
  80. package/dist/todo/types.js +2 -2
  81. package/dist/todo/types.js.map +1 -1
  82. package/dist/todo/view.js +6 -4
  83. package/dist/todo/view.js.map +1 -1
  84. package/dist/whisper/inbox.js +3 -3
  85. package/dist/whisper/inbox.js.map +1 -1
  86. package/dist/whisper/route.d.ts +1 -1
  87. package/dist/whisper/route.d.ts.map +1 -1
  88. package/dist/whisper/route.js +13 -13
  89. package/dist/whisper/route.js.map +1 -1
  90. package/doc/CLAUDE.md +1 -1
  91. package/doc/fs-codec.ts +1 -1
  92. package/doc/renderers.tsx +4 -3
  93. package/doc/toolbar.tsx +12 -9
  94. package/launcher/types.ts +2 -2
  95. package/launcher/view.tsx +12 -8
  96. package/mcp/mcp-server.ts +393 -0
  97. package/mcp/server.ts +2 -0
  98. package/mcp/service.ts +18 -0
  99. package/mcp/types.ts +6 -0
  100. package/mindmap/branch.tsx +121 -22
  101. package/mindmap/mindmap.css +52 -0
  102. package/mindmap/sidebar.tsx +9 -6
  103. package/mindmap/view.tsx +40 -4
  104. package/package.json +30 -3
  105. package/sensor-demo/service.ts +6 -5
  106. package/sensor-generator/action.ts +1 -1
  107. package/sim/service.ts +41 -41
  108. package/table/view.tsx +7 -2
  109. package/todo/types.ts +2 -2
  110. package/todo/view.tsx +9 -10
  111. package/whisper/inbox.ts +3 -3
  112. package/whisper/route.ts +13 -13
  113. package/board/board.test.ts +0 -212
  114. package/brahman/brahman.test.ts +0 -855
  115. package/dist/mindmap/radial-tree.d.ts +0 -14
  116. package/dist/mindmap/radial-tree.d.ts.map +0 -1
  117. package/dist/mindmap/radial-tree.js +0 -184
  118. package/dist/mindmap/radial-tree.js.map +0 -1
  119. package/dist/mindmap/use-tree-data.d.ts +0 -14
  120. package/dist/mindmap/use-tree-data.d.ts.map +0 -1
  121. package/dist/mindmap/use-tree-data.js +0 -95
  122. package/dist/mindmap/use-tree-data.js.map +0 -1
  123. package/doc/fs-codec.test.ts +0 -119
  124. package/doc/markdown.test.ts +0 -152
  125. package/sim/sim.test.ts +0 -282
@@ -0,0 +1,393 @@
1
+ // Treenity MCP Server — exposes tree store as MCP tools
2
+ // StreamableHTTP transport, stateless, token auth via ?token= or Authorization header
3
+
4
+ import { requestApproval } from '#agent/guardian';
5
+ import { matchesAny } from '@treenity/core/glob';
6
+ import { AiPolicy } from '#agent/types';
7
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
9
+ import { createNode, getComponent } from '@treenity/core';
10
+ import { verifyViewSource } from '@treenity/core/mods/uix/verify';
11
+ import { TypeCatalog } from '@treenity/core/schema/catalog';
12
+ import { executeAction } from '@treenity/core/server/actions';
13
+ import { buildClaims, resolveToken, type Session, withAcl } from '@treenity/core/server/auth';
14
+ import { deployPrefab } from '@treenity/core/server/prefab';
15
+ import type { Tree } from '@treenity/core/tree';
16
+ import { existsSync, readFileSync } from 'node:fs';
17
+ import { createServer, type Server } from 'node:http';
18
+ import { createServer as createHttpsServer } from 'node:https';
19
+ import { homedir } from 'node:os';
20
+ import { join } from 'node:path';
21
+ import { z } from 'zod/v3';
22
+
23
+ function loadDevCerts(): { key: Buffer; cert: Buffer } | null {
24
+ const home = homedir();
25
+ const keyPath = join(home, 'localhost+2-key.pem');
26
+ const certPath = join(home, 'localhost+2.pem');
27
+ if (!existsSync(keyPath) || !existsSync(certPath)) return null;
28
+ return { key: readFileSync(keyPath), cert: readFileSync(certPath) };
29
+ }
30
+
31
+ // Guardian policy check for MCP write operations
32
+ // Reads global policy from /agents/guardian, checks deny → escalate → allow
33
+ export type McpGuardianResult = { allowed: true } | { allowed: false; reason: string };
34
+
35
+
36
+ export async function checkMcpGuardian(store: Tree, toolName: string, input?: string): Promise<McpGuardianResult> {
37
+ try {
38
+ const guardianNode = await store.get('/agents/guardian');
39
+ if (!guardianNode) return { allowed: false, reason: 'no Guardian configured at /agents/guardian — all writes denied' };
40
+
41
+ const policy = getComponent(guardianNode, AiPolicy);
42
+ if (!policy || policy.$type !== 'ai.policy') return { allowed: false, reason: 'invalid Guardian policy type — writes denied' };
43
+
44
+ const allow = (policy.allow as string[]) ?? [];
45
+ const deny = (policy.deny as string[]) ?? [];
46
+ const escalate = (policy.escalate as string[]) ?? [];
47
+
48
+ if (matchesAny(deny, toolName)) return { allowed: false, reason: `denied by Guardian: ${toolName}` };
49
+
50
+ if (matchesAny(escalate, toolName)) {
51
+ const approved = await requestApproval(store, {
52
+ agentPath: '/agents/mcp',
53
+ role: 'mcp',
54
+ tool: toolName,
55
+ input: input?.slice(0, 500) ?? '',
56
+ reason: 'MCP tool requires approval',
57
+ });
58
+ return approved
59
+ ? { allowed: true }
60
+ : { allowed: false, reason: 'denied by human' };
61
+ }
62
+
63
+ if (matchesAny(allow, toolName)) return { allowed: true };
64
+
65
+ // Unknown tool via MCP → deny by default (safer than escalate for external callers)
66
+ return { allowed: false, reason: `not in Guardian allow list: ${toolName}. Use Treenity Agent Office for write operations.` };
67
+ } catch (err) {
68
+ console.error('[mcp-guardian] failed to check policy:', err);
69
+ return { allowed: false, reason: 'Guardian check failed — writes denied for safety' };
70
+ }
71
+ }
72
+
73
+ /** Compact YAML-like serializer — readable for LLMs, much less noisy than JSON */
74
+ function yaml(val: unknown, depth = 0, maxStr = 300): string {
75
+ const pad = ' '.repeat(depth);
76
+ if (val == null) return 'null';
77
+ if (typeof val === 'boolean' || typeof val === 'number') return String(val);
78
+ if (typeof val === 'string') {
79
+ if (maxStr < Infinity && val.length > maxStr) return JSON.stringify(val.slice(0, maxStr) + '…');
80
+ return /[\n\r:#\[\]{}",]/.test(val) || val === '' ? JSON.stringify(val) : val;
81
+ }
82
+ if (Array.isArray(val)) {
83
+ if (!val.length) return '[]';
84
+ if (val.every(v => typeof v !== 'object' || v == null)) {
85
+ const inline = `[${val.map(v => yaml(v, 0, maxStr)).join(', ')}]`;
86
+ if (inline.length < 80) return inline;
87
+ }
88
+ return val.map(item => {
89
+ if (typeof item !== 'object' || item == null) return `${pad}- ${yaml(item, 0, maxStr)}`;
90
+ const inner = yaml(item, depth + 1, maxStr);
91
+ const lines = inner.split('\n');
92
+ return lines.length === 1
93
+ ? `${pad}- ${lines[0].trimStart()}`
94
+ : `${pad}- ${lines[0].trimStart()}\n${lines.slice(1).map(l => `${pad} ${l.trimStart()}`).join('\n')}`;
95
+ }).join('\n');
96
+ }
97
+ if (typeof val === 'object') {
98
+ const entries = Object.entries(val as Record<string, unknown>);
99
+ if (!entries.length) return '{}';
100
+ return entries.map(([k, v]) => {
101
+ if (v != null && typeof v === 'object') {
102
+ const inner = yaml(v, depth + 1, maxStr);
103
+ if (!inner.includes('\n') && inner.length < 60) return `${pad}${k}: ${inner.trimStart()}`;
104
+ return `${pad}${k}:\n${inner}`;
105
+ }
106
+ return `${pad}${k}: ${yaml(v, 0, maxStr)}`;
107
+ }).join('\n');
108
+ }
109
+ return String(val);
110
+ }
111
+
112
+ const text = (s: string) => ({ content: [{ type: 'text' as const, text: s }] });
113
+ const catalog = new TypeCatalog();
114
+
115
+ function dataKeys(node: Record<string, unknown>) {
116
+ return Object.keys(node).filter(k => !k.startsWith('$'));
117
+ }
118
+
119
+ export async function buildMcpServer(store: Tree, session: Session, claims?: string[]) {
120
+ claims ??= await buildClaims(store, session.userId);
121
+ const aclStore = withAcl(store, session.userId, claims);
122
+
123
+ const mcp = new McpServer({ name: 'treenity', version: '1.0.0' });
124
+
125
+ mcp.registerTool(
126
+ 'get_node',
127
+ {
128
+ description: 'Read a node by path. Returns full untruncated values.',
129
+ inputSchema: { path: z.string() },
130
+ },
131
+ async ({ path }) => {
132
+ const node = await aclStore.get(path);
133
+ return text(node ? yaml(node, 0, Infinity) : `not found: ${path}`);
134
+ },
135
+ );
136
+
137
+ mcp.registerTool(
138
+ 'list_children',
139
+ {
140
+ description: 'List children of a node. Long string values may be truncated — use get_node for full data.',
141
+ inputSchema: {
142
+ path: z.string(),
143
+ depth: z.number().optional(),
144
+ detail: z.boolean().optional().describe('Show first-level fields and component types'),
145
+ full: z.boolean().optional().describe('Return complete YAML of each node'),
146
+ },
147
+ },
148
+ async ({ path, depth, detail, full }) => {
149
+ const ctx = { queryContextPath: path, userId: session.userId };
150
+ const result = await aclStore.getChildren(path, { depth }, ctx);
151
+ const { items, total, truncated } = result;
152
+ const truncNote = truncated ? '\n⚠️ Results truncated — ACL scan limit reached. Use query mounts for large collections.' : '';
153
+
154
+ if (full) return text(yaml({ total, truncated, items }));
155
+
156
+ if (detail) {
157
+ const lines = items.map(n => {
158
+ const name = n.$path.split('/').at(-1);
159
+ const keys = dataKeys(n);
160
+ const header = n.$type === 'dir' ? `${name}/` : `${name}: ${n.$type} [${keys.length}]`;
161
+ const fields = keys.map(k => {
162
+ const v = (n as Record<string, unknown>)[k];
163
+ if (v && typeof v === 'object' && '$type' in (v as object))
164
+ return ` ${k}: ${(v as Record<string, unknown>).$type}`;
165
+ if (Array.isArray(v)) return ` ${k}: [${v.length}]`;
166
+ const s = String(v);
167
+ return ` ${k}: ${s.length > 60 ? s.slice(0, 60) + '…' : s}`;
168
+ });
169
+ return header + (fields.length ? '\n' + fields.join('\n') : '');
170
+ });
171
+ return text(lines.join('\n') + `\n(${total} total)` + truncNote);
172
+ }
173
+
174
+ const lines = items.map(n => {
175
+ const name = n.$path.split('/').at(-1);
176
+ if (n.$type === 'dir') return `${name}/`;
177
+ return `${name} ${n.$type} [${dataKeys(n).length}]`;
178
+ });
179
+ return text(lines.join('\n') + (total > items.length ? `\n(${total} total)` : '') + truncNote);
180
+ },
181
+ );
182
+
183
+ mcp.registerTool(
184
+ 'set_node',
185
+ {
186
+ description: 'Create or update a node. May require Guardian approval.',
187
+ inputSchema: {
188
+ path: z.string(),
189
+ type: z.string(),
190
+ components: z.record(z.unknown()).optional(),
191
+ acl: z.array(z.object({ g: z.string(), p: z.number() })).optional(),
192
+ owner: z.string().optional(),
193
+ },
194
+ },
195
+ async ({ path, type, components, acl, owner }) => {
196
+ const guard = await checkMcpGuardian(store, 'mcp__treenity__set_node', JSON.stringify({ path, type }));
197
+ if (!guard.allowed) return text(`🛑 Guardian: ${guard.reason}`);
198
+ const existing = await aclStore.get(path);
199
+ const node = existing ?? createNode(path, type);
200
+ if (!existing) node.$type = type;
201
+ if (components) {
202
+ for (const [k, v] of Object.entries(components)) {
203
+ if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
204
+ const comp = v as Record<string, unknown> | null;
205
+ if (comp && typeof comp === 'object' && comp.$type === type) {
206
+ for (const [fk, fv] of Object.entries(comp)) {
207
+ if (fk !== '$type') node[fk] = fv;
208
+ }
209
+ continue;
210
+ }
211
+ node[k] = v;
212
+ }
213
+ }
214
+ if (acl) node.$acl = acl;
215
+ if (owner) node.$owner = owner;
216
+ await aclStore.set(node);
217
+ return text(yaml(node));
218
+ },
219
+ );
220
+
221
+ mcp.registerTool(
222
+ 'execute',
223
+ {
224
+ description: 'Execute an action on a node or component. Actions are methods registered on types. May require Guardian approval.',
225
+ inputSchema: {
226
+ path: z.string(),
227
+ action: z.string(),
228
+ type: z.string().optional(),
229
+ key: z.string().optional(),
230
+ data: z.record(z.unknown()).optional(),
231
+ },
232
+ },
233
+ async ({ path, action, type, key, data }) => {
234
+ const guard = await checkMcpGuardian(store, 'mcp__treenity__execute', JSON.stringify({ path, action }));
235
+ if (!guard.allowed) return text(`🛑 Guardian: ${guard.reason}`);
236
+ // Action-level Guardian check — allows operators to deny/escalate specific actions
237
+ // e.g. deny "mcp__treenity__execute:run" to block flow execution
238
+ const actionGuard = await checkMcpGuardian(store, `mcp__treenity__execute:${action}`, JSON.stringify({ path }));
239
+ if (!actionGuard.allowed) return text(`🛑 Guardian: ${actionGuard.reason}`);
240
+ const result = await executeAction(aclStore, path, type, key, action, data);
241
+ return text(yaml(result ?? { ok: true }));
242
+ },
243
+ );
244
+
245
+ mcp.registerTool(
246
+ 'deploy_prefab',
247
+ {
248
+ description: 'Deploy a module prefab (node tree template) to a target path. Idempotent: skips existing nodes. Browse available prefabs via list_children /sys/mods.',
249
+ inputSchema: {
250
+ source: z.string().describe('Prefab path: /sys/mods/{mod}/prefabs/{name}'),
251
+ target: z.string().describe('Target path where nodes will be created'),
252
+ allowAbsolute: z.boolean().optional().describe('Allow prefab to write outside target (e.g. /sys/autostart refs). Default: false'),
253
+ },
254
+ },
255
+ async ({ source, target, allowAbsolute }) => {
256
+ const guard = await checkMcpGuardian(store, 'mcp__treenity__deploy_prefab', JSON.stringify({ source, target }));
257
+ if (!guard.allowed) return text(`🛑 Guardian: ${guard.reason}`);
258
+ const result = await deployPrefab(aclStore, source, target, { allowAbsolute });
259
+ return text(yaml(result));
260
+ },
261
+ );
262
+
263
+ mcp.registerTool(
264
+ 'compile_view',
265
+ {
266
+ description: 'Verify that a UIX view source compiles correctly. Pass path to check an existing type node, or source to verify before writing.',
267
+ inputSchema: {
268
+ path: z.string().optional().describe('Path to type node (e.g. /sys/types/cosmos/system) — reads view.source'),
269
+ source: z.string().optional().describe('Raw JSX/TSX source to verify directly'),
270
+ },
271
+ },
272
+ async ({ path, source }) => {
273
+ let code = source;
274
+ if (!code) {
275
+ if (!path) return text('error: provide path or source');
276
+ const node = await aclStore.get(path);
277
+ if (!node) return text(`not found: ${path}`);
278
+ code = (node as any)?.view?.source;
279
+ if (!code || typeof code !== 'string') return text(`no view.source on ${path}`);
280
+ }
281
+ const result = verifyViewSource(code);
282
+ return text(yaml(result));
283
+ },
284
+ );
285
+
286
+ mcp.registerTool(
287
+ 'remove_node',
288
+ {
289
+ description: 'Remove a node by path. May be denied by Guardian.',
290
+ inputSchema: { path: z.string() },
291
+ },
292
+ async ({ path }) => {
293
+ const guard = await checkMcpGuardian(store, 'mcp__treenity__remove_node', path);
294
+ if (!guard.allowed) return text(`🛑 Guardian: ${guard.reason}`);
295
+ const ok = await aclStore.remove(path);
296
+ return text(ok ? `removed: ${path}` : `not found: ${path}`);
297
+ },
298
+ );
299
+
300
+ // ── Discovery tools — powered by TypeCatalog ──
301
+
302
+ mcp.registerTool(
303
+ 'catalog',
304
+ {
305
+ description: 'List all registered types with title, properties, and action names. Use this first to discover what types exist.',
306
+ inputSchema: {},
307
+ },
308
+ async () => text(yaml(catalog.list())),
309
+ );
310
+
311
+ mcp.registerTool(
312
+ 'describe_type',
313
+ {
314
+ description: 'Get full schema of a type: properties, actions with argument types, and cross-references to other types. Use after catalog to understand a specific type deeply.',
315
+ inputSchema: { type: z.string().describe('Type name, e.g. "cafe.contact" or "board.task"') },
316
+ },
317
+ async ({ type: typeName }) => {
318
+ const desc = catalog.describe(typeName);
319
+ return text(desc ? yaml(desc) : `type not found: ${typeName}`);
320
+ },
321
+ );
322
+
323
+ mcp.registerTool(
324
+ 'search_types',
325
+ {
326
+ description: 'Search types by keyword across names, titles, property names, and action names. Use to find types relevant to a task.',
327
+ inputSchema: { query: z.string().describe('Search keyword, e.g. "order", "mail", "contact"') },
328
+ },
329
+ async ({ query }) => text(yaml(catalog.search(query))),
330
+ );
331
+
332
+ return mcp;
333
+ }
334
+
335
+ export function extractToken(req: import('node:http').IncomingMessage): string | null {
336
+ const auth = req.headers.authorization;
337
+ if (typeof auth === 'string' && auth.startsWith('Bearer ')) return auth.slice(7);
338
+
339
+ const qs = (req.url ?? '').split('?')[1];
340
+ if (qs) {
341
+ const match = qs.match(/(?:^|&)token=([^&]+)/);
342
+ if (match) return decodeURIComponent(match[1]);
343
+ }
344
+ return null;
345
+ }
346
+
347
+ /** Create MCP HTTP server. Returns server handle for lifecycle management. */
348
+ export function createMcpHttpServer(store: Tree, port: number): Server {
349
+ const tls = loadDevCerts();
350
+
351
+ const handler = async (req: import('node:http').IncomingMessage, res: import('node:http').ServerResponse) => {
352
+ res.setHeader('Access-Control-Allow-Origin', '*');
353
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
354
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, mcp-session-id');
355
+ if (req.method === 'OPTIONS') {
356
+ res.writeHead(204);
357
+ res.end();
358
+ return;
359
+ }
360
+
361
+ const url = (req.url ?? '/').split('?')[0];
362
+ if (url !== '/mcp') {
363
+ res.writeHead(404);
364
+ res.end('not found');
365
+ return;
366
+ }
367
+
368
+ const token = extractToken(req);
369
+ let session: Session | null = null;
370
+ let devClaims: string[] | undefined;
371
+ if (token) {
372
+ session = await resolveToken(store, token);
373
+ } else if (!process.env.TENANT) {
374
+ console.warn('[mcp] ⚠️ DEV MODE: no TENANT set — all MCP requests have ADMIN access. Set TENANT env for production.');
375
+ session = { userId: 'mcp-dev' } as Session;
376
+ devClaims = ['u:mcp-dev', 'authenticated', 'admins'];
377
+ }
378
+ if (!session) {
379
+ res.writeHead(401, { 'Content-Type': 'text/plain' });
380
+ res.end('token required (?token= or Authorization: Bearer)');
381
+ return;
382
+ }
383
+
384
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
385
+ const mcp = await buildMcpServer(store, session, devClaims);
386
+ await mcp.connect(transport);
387
+ await transport.handleRequest(req, res);
388
+ };
389
+
390
+ const server = tls ? createHttpsServer(tls, handler) : createServer(handler);
391
+ server.listen(port, '127.0.0.1', () => console.log(`treenity mcp ${tls ? 'https' : 'http'}://localhost:${port}/mcp`));
392
+ return server;
393
+ }
package/mcp/server.ts ADDED
@@ -0,0 +1,2 @@
1
+ import './types';
2
+ import './service';
package/mcp/service.ts ADDED
@@ -0,0 +1,18 @@
1
+ // MCP autostart service — starts/stops the MCP HTTP server via tree lifecycle
2
+
3
+ import { getComponent, register } from '@treenity/core';
4
+ import { createMcpHttpServer } from './mcp-server';
5
+ import { McpConfig } from './types';
6
+
7
+ register('mcp.server', 'service', async (node, ctx) => {
8
+ const config = getComponent(node, McpConfig);
9
+ const port = config?.port ?? (Number(process.env.MCP_PORT) || 3212);
10
+ const server = createMcpHttpServer(ctx.tree, port);
11
+
12
+ return {
13
+ stop: async () => {
14
+ server.close();
15
+ console.log(`[mcp] stopped :${port}`);
16
+ },
17
+ };
18
+ });
package/mcp/types.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { registerType } from '@treenity/core/comp';
2
+
3
+ export class McpConfig {
4
+ port = 3212;
5
+ }
6
+ registerType('mcp.server', McpConfig);
@@ -3,19 +3,30 @@
3
3
 
4
4
  import type { NodeData } from '@treenity/core';
5
5
  import { useChildren } from '@treenity/react/hooks';
6
- import { createContext, useContext } from 'react';
6
+ import { createContext, useContext, useEffect, useRef } from 'react';
7
+
8
+ export type EditingAt = {
9
+ parentPath: string;
10
+ side: 'left' | 'right';
11
+ color: string;
12
+ } | null;
7
13
 
8
14
  export type MindMapState = {
9
15
  expanded: Set<string>;
10
16
  selectedPath: string | null;
17
+ editingAt: EditingAt;
11
18
  onToggle: (path: string) => void;
12
19
  onSelect: (path: string) => void;
20
+ onAddChild: (parentPath: string, side: 'left' | 'right', color: string) => void;
21
+ onCommitAdd: (parentPath: string, name: string) => void;
22
+ onCancelAdd: () => void;
23
+ onDelete: (path: string) => void;
13
24
  };
14
25
 
15
26
  export const MindMapCtx = createContext<MindMapState>(null!);
16
27
 
17
28
  const LEVEL_W = 200;
18
- const SPACING = 36;
29
+ const SPACING = 40;
19
30
 
20
31
  function basename(path: string): string {
21
32
  if (path === '/') return '/';
@@ -41,7 +52,7 @@ type BranchProps = {
41
52
  };
42
53
 
43
54
  export function MindMapBranch({ node, side, color, depth }: BranchProps) {
44
- const { expanded, selectedPath, onToggle, onSelect } = useContext(MindMapCtx);
55
+ const { expanded, selectedPath, onToggle, onSelect, onAddChild } = useContext(MindMapCtx);
45
56
  const isExpanded = expanded.has(node.$path);
46
57
  const isLeft = side === 'left';
47
58
  const isSelected = selectedPath === node.$path;
@@ -50,8 +61,7 @@ export function MindMapBranch({ node, side, color, depth }: BranchProps) {
50
61
  const strokeW = Math.max(1.5, 3 - depth * 0.4);
51
62
 
52
63
  return (
53
- <g>
54
- {/* Click = expand/collapse */}
64
+ <g className="mm-branch">
55
65
  <g
56
66
  className={`mm-node${isSelected ? ' mm-node-selected' : ''}`}
57
67
  onClick={() => onToggle(node.$path)}
@@ -60,9 +70,9 @@ export function MindMapBranch({ node, side, color, depth }: BranchProps) {
60
70
  {/* Dot */}
61
71
  <circle r={isSelected ? 5 : 3.5} fill={color} className="mm-dot" />
62
72
 
63
- {/* Label */}
73
+ {/* Label — offset from dot to avoid overlap with child curves */}
64
74
  <text
65
- x={isLeft ? -12 : 12}
75
+ x={isLeft ? -14 : 14}
66
76
  textAnchor={isLeft ? 'end' : 'start'}
67
77
  dominantBaseline="central"
68
78
  className="mm-label"
@@ -74,7 +84,7 @@ export function MindMapBranch({ node, side, color, depth }: BranchProps) {
74
84
  {/* Type tag */}
75
85
  {type && depth <= 2 && (
76
86
  <text
77
- x={isLeft ? -12 - name.length * 7.5 - 8 : 12 + name.length * 7.5 + 8}
87
+ x={isLeft ? -14 - name.length * 7.5 - 6 : 14 + name.length * 7.5 + 6}
78
88
  textAnchor={isLeft ? 'end' : 'start'}
79
89
  dominantBaseline="central"
80
90
  className="mm-type-tag"
@@ -86,14 +96,24 @@ export function MindMapBranch({ node, side, color, depth }: BranchProps) {
86
96
 
87
97
  {/* Hit area */}
88
98
  <rect
89
- x={isLeft ? -12 - name.length * 8 : -6}
90
- y={-14}
91
- width={name.length * 8 + 20}
92
- height={28}
99
+ x={isLeft ? -14 - name.length * 8 : -6}
100
+ y={-16}
101
+ width={name.length * 8 + 24}
102
+ height={32}
93
103
  fill="transparent"
94
104
  />
95
105
  </g>
96
106
 
107
+ {/* "+" add child button — visible on hover */}
108
+ <g
109
+ className="mm-add-btn"
110
+ transform={`translate(${isLeft ? -14 - name.length * 7.5 - 20 : 14 + name.length * 7.5 + 20},0)`}
111
+ onClick={e => { e.stopPropagation(); onAddChild(node.$path, side, color); }}
112
+ >
113
+ <circle r={9} className="mm-add-bg" />
114
+ <text textAnchor="middle" dominantBaseline="central" className="mm-add-icon">+</text>
115
+ </g>
116
+
97
117
  {/* Children (only fetched when expanded) */}
98
118
  {isExpanded && (
99
119
  <BranchChildren
@@ -108,6 +128,61 @@ export function MindMapBranch({ node, side, color, depth }: BranchProps) {
108
128
  );
109
129
  }
110
130
 
131
+ // Inline input for naming new nodes
132
+ function InlineInput({ x, y, side, onCommit, onCancel }: {
133
+ x: number;
134
+ y: number;
135
+ side: 'left' | 'right';
136
+ onCommit: (name: string) => void;
137
+ onCancel: () => void;
138
+ }) {
139
+ const ref = useRef<HTMLInputElement>(null);
140
+
141
+ useEffect(() => {
142
+ // Focus after foreignObject mounts
143
+ setTimeout(() => ref.current?.focus(), 50);
144
+ }, []);
145
+
146
+ const handleKey = (e: React.KeyboardEvent) => {
147
+ e.stopPropagation();
148
+ if (e.key === 'Enter') {
149
+ const val = ref.current?.value.trim();
150
+ if (val) onCommit(val);
151
+ else onCancel();
152
+ }
153
+ if (e.key === 'Escape') onCancel();
154
+ };
155
+
156
+ const handleBlur = () => {
157
+ const val = ref.current?.value.trim();
158
+ if (val) onCommit(val);
159
+ else onCancel();
160
+ };
161
+
162
+ const isLeft = side === 'left';
163
+ const inputW = 140;
164
+
165
+ return (
166
+ <g transform={`translate(${x},${y})`}>
167
+ <circle r={3.5} fill="var(--accent)" />
168
+ <foreignObject
169
+ x={isLeft ? -inputW - 14 : 14}
170
+ y={-12}
171
+ width={inputW}
172
+ height={24}
173
+ >
174
+ <input
175
+ ref={ref}
176
+ className="mm-inline-input"
177
+ placeholder="node name..."
178
+ onKeyDown={handleKey}
179
+ onBlur={handleBlur}
180
+ />
181
+ </foreignObject>
182
+ </g>
183
+ );
184
+ }
185
+
111
186
  type ChildrenProps = {
112
187
  path: string;
113
188
  side: 'left' | 'right';
@@ -117,17 +192,22 @@ type ChildrenProps = {
117
192
  };
118
193
 
119
194
  function BranchChildren({ path, side, color, depth, strokeW }: ChildrenProps) {
195
+ const { editingAt, onCommitAdd, onCancelAdd } = useContext(MindMapCtx);
120
196
  const children = useChildren(path, { watch: true, watchNew: true });
121
- if (children.length === 0) return null;
122
197
 
123
198
  const isLeft = side === 'left';
124
199
  const dx = isLeft ? -LEVEL_W : LEVEL_W;
125
- const totalH = (children.length - 1) * SPACING;
200
+ const isEditing = editingAt?.parentPath === path;
201
+ const totalCount = children.length + (isEditing ? 1 : 0);
202
+
203
+ if (totalCount === 0) return null;
204
+
205
+ const totalH = (totalCount - 1) * SPACING;
126
206
  const startY = -totalH / 2;
127
207
 
128
208
  return (
129
209
  <>
130
- {/* Curves from this node to each child — drawn at parent level */}
210
+ {/* Curves to existing children */}
131
211
  {children.map((child, i) => {
132
212
  const cy = startY + i * SPACING;
133
213
  return (
@@ -144,20 +224,39 @@ function BranchChildren({ path, side, color, depth, strokeW }: ChildrenProps) {
144
224
  );
145
225
  })}
146
226
 
147
- {/* Child nodes */}
227
+ {/* Curve to new node slot */}
228
+ {isEditing && (
229
+ <path
230
+ d={sCurve(dx, startY + children.length * SPACING)}
231
+ fill="none"
232
+ stroke="var(--accent)"
233
+ strokeWidth={strokeW}
234
+ strokeOpacity={0.5}
235
+ strokeLinecap="round"
236
+ strokeDasharray="6 4"
237
+ />
238
+ )}
239
+
240
+ {/* Existing child nodes */}
148
241
  {children.map((child, i) => {
149
242
  const cy = startY + i * SPACING;
150
243
  return (
151
244
  <g key={child.$path} transform={`translate(${dx},${cy})`}>
152
- <MindMapBranch
153
- node={child}
154
- side={side}
155
- color={color}
156
- depth={depth + 1}
157
- />
245
+ <MindMapBranch node={child} side={side} color={color} depth={depth + 1} />
158
246
  </g>
159
247
  );
160
248
  })}
249
+
250
+ {/* Inline input for new node */}
251
+ {isEditing && (
252
+ <InlineInput
253
+ x={dx}
254
+ y={startY + children.length * SPACING}
255
+ side={side}
256
+ onCommit={name => onCommitAdd(path, name)}
257
+ onCancel={onCancelAdd}
258
+ />
259
+ )}
161
260
  </>
162
261
  );
163
262
  }