@treenity/mods 3.0.2 → 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.
- package/agent/client.ts +2 -0
- package/agent/guardian.ts +492 -0
- package/agent/seed.ts +74 -0
- package/agent/server.ts +4 -0
- package/agent/service.ts +644 -0
- package/agent/types.ts +184 -0
- package/agent/view.tsx +431 -0
- package/mcp/mcp-server.ts +393 -0
- package/mcp/server.ts +2 -0
- package/mcp/service.ts +18 -0
- package/mcp/types.ts +6 -0
- package/package.json +5 -2
- package/dist/mindmap/radial-tree.d.ts +0 -14
- package/dist/mindmap/radial-tree.d.ts.map +0 -1
- package/dist/mindmap/radial-tree.js +0 -184
- package/dist/mindmap/radial-tree.js.map +0 -1
- package/dist/mindmap/use-tree-data.d.ts +0 -14
- package/dist/mindmap/use-tree-data.d.ts.map +0 -1
- package/dist/mindmap/use-tree-data.js +0 -95
- package/dist/mindmap/use-tree-data.js.map +0 -1
|
@@ -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
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@treenity/mods",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.3",
|
|
4
4
|
"description": "Official Treenity modules — board, sim, cafe, doc, mindmap, and more.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -38,12 +38,14 @@
|
|
|
38
38
|
},
|
|
39
39
|
"files": [
|
|
40
40
|
"dist",
|
|
41
|
+
"agent",
|
|
41
42
|
"board",
|
|
42
43
|
"brahman",
|
|
43
44
|
"cafe",
|
|
44
45
|
"canary",
|
|
45
46
|
"doc",
|
|
46
47
|
"launcher",
|
|
48
|
+
"mcp",
|
|
47
49
|
"mindmap",
|
|
48
50
|
"sensor-demo",
|
|
49
51
|
"sensor-generator",
|
|
@@ -73,7 +75,8 @@
|
|
|
73
75
|
}
|
|
74
76
|
},
|
|
75
77
|
"dependencies": {
|
|
76
|
-
"@grammyjs/runner": "^2.0.3"
|
|
78
|
+
"@grammyjs/runner": "^2.0.3",
|
|
79
|
+
"@modelcontextprotocol/sdk": "^1.27.1"
|
|
77
80
|
},
|
|
78
81
|
"optionalDependencies": {
|
|
79
82
|
"@huggingface/transformers": "^3.8.1",
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
import 'd3-transition';
|
|
2
|
-
import type { TreeItem } from './use-tree-data';
|
|
3
|
-
type Props = {
|
|
4
|
-
data: TreeItem;
|
|
5
|
-
selectedPath: string | null;
|
|
6
|
-
onSelect: (path: string) => void;
|
|
7
|
-
onToggle: (path: string) => void;
|
|
8
|
-
branchColors: Map<string, string>;
|
|
9
|
-
width: number;
|
|
10
|
-
height: number;
|
|
11
|
-
};
|
|
12
|
-
export declare function MindMapTree({ data, selectedPath, onSelect, onToggle, branchColors, width, height }: Props): import("react/jsx-runtime").JSX.Element;
|
|
13
|
-
export {};
|
|
14
|
-
//# sourceMappingURL=radial-tree.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"radial-tree.d.ts","sourceRoot":"","sources":["../../mindmap/radial-tree.tsx"],"names":[],"mappings":"AAKA,OAAO,eAAe,CAAC;AAGvB,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAEhD,KAAK,KAAK,GAAG;IACX,IAAI,EAAE,QAAQ,CAAC;IACf,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAClC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAkGF,wBAAgB,WAAW,CAAC,EAAE,IAAI,EAAE,YAAY,EAAE,QAAQ,EAAE,QAAQ,EAAE,YAAY,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,KAAK,2CA6QzG"}
|
|
@@ -1,184 +0,0 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
// Miro-style mind map — horizontal balanced tree with organic bezier curves
|
|
3
|
-
// Pure SVG: text labels + colored branches, no foreignObject
|
|
4
|
-
import { hierarchy, tree as d3tree } from 'd3-hierarchy';
|
|
5
|
-
import { select } from 'd3-selection';
|
|
6
|
-
import 'd3-transition';
|
|
7
|
-
import { zoom as d3zoom, zoomIdentity } from 'd3-zoom';
|
|
8
|
-
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
|
9
|
-
// Type → icon mapping (simple SVG paths)
|
|
10
|
-
const TYPE_ICONS = {
|
|
11
|
-
dir: 'M2 4h5l2 2h9v12H2V4z',
|
|
12
|
-
ref: 'M10 2a8 8 0 100 16 8 8 0 000-16zm1 4v4l3.5 2.1-.8 1.3L9 11V6h2z',
|
|
13
|
-
user: 'M12 4a4 4 0 110 8 4 4 0 010-8zM12 14c-4.42 0-8 1.79-8 4v2h16v-2c0-2.21-3.58-4-8-4z',
|
|
14
|
-
root: 'M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z',
|
|
15
|
-
};
|
|
16
|
-
function getIcon(type) {
|
|
17
|
-
if (TYPE_ICONS[type])
|
|
18
|
-
return TYPE_ICONS[type];
|
|
19
|
-
const base = type.split('.')[0];
|
|
20
|
-
return TYPE_ICONS[base] ?? null;
|
|
21
|
-
}
|
|
22
|
-
// Measure text width roughly (8px per char at 13px font, 6.5px at 11px)
|
|
23
|
-
function textWidth(str, fontSize) {
|
|
24
|
-
return str.length * fontSize * 0.62;
|
|
25
|
-
}
|
|
26
|
-
function splitTree(root) {
|
|
27
|
-
const left = [];
|
|
28
|
-
const right = [];
|
|
29
|
-
root.children.forEach((child, i) => {
|
|
30
|
-
const tagged = { ...child, _side: (i % 2 === 0 ? 'right' : 'left') };
|
|
31
|
-
if (i % 2 === 0)
|
|
32
|
-
right.push(tagged);
|
|
33
|
-
else
|
|
34
|
-
left.push(tagged);
|
|
35
|
-
});
|
|
36
|
-
return { ...root, children: [...right, ...left] };
|
|
37
|
-
}
|
|
38
|
-
function tagSide(item, side) {
|
|
39
|
-
return {
|
|
40
|
-
...item,
|
|
41
|
-
_side: item._side ?? side,
|
|
42
|
-
children: item.children.map(c => tagSide(c, side)),
|
|
43
|
-
};
|
|
44
|
-
}
|
|
45
|
-
function buildSide(root, children, side) {
|
|
46
|
-
return {
|
|
47
|
-
...root,
|
|
48
|
-
_side: undefined,
|
|
49
|
-
children: children.map(c => tagSide(c, side)),
|
|
50
|
-
};
|
|
51
|
-
}
|
|
52
|
-
// Organic cubic bezier — Miro-style smooth S-curve
|
|
53
|
-
function linkPath(sx, sy, tx, ty) {
|
|
54
|
-
const dx = tx - sx;
|
|
55
|
-
const cp = Math.abs(dx) * 0.5;
|
|
56
|
-
return `M${sx},${sy} C${sx + (dx > 0 ? cp : -cp)},${sy} ${tx - (dx > 0 ? cp : -cp)},${ty} ${tx},${ty}`;
|
|
57
|
-
}
|
|
58
|
-
function layoutHalf(root, children, side, height) {
|
|
59
|
-
if (children.length === 0)
|
|
60
|
-
return null;
|
|
61
|
-
const subtree = buildSide(root, children, side);
|
|
62
|
-
const h = hierarchy(subtree, d => d.children);
|
|
63
|
-
const nodeCount = h.descendants().length;
|
|
64
|
-
const treeHeight = Math.max(height * 0.8, nodeCount * 32);
|
|
65
|
-
const layout = d3tree()
|
|
66
|
-
.size([treeHeight, 220 * Math.max(1, h.height)])
|
|
67
|
-
.separation((a, b) => (a.parent === b.parent ? 1 : 1.5));
|
|
68
|
-
layout(h);
|
|
69
|
-
// Convert: d3tree gives vertical layout (x=vertical, y=horizontal)
|
|
70
|
-
// We flip and mirror for left side
|
|
71
|
-
for (const node of h.descendants()) {
|
|
72
|
-
const lNode = node;
|
|
73
|
-
if (side === 'left') {
|
|
74
|
-
lNode._rx = -lNode.y;
|
|
75
|
-
lNode._ry = lNode.x - treeHeight / 2;
|
|
76
|
-
}
|
|
77
|
-
else {
|
|
78
|
-
lNode._rx = lNode.y;
|
|
79
|
-
lNode._ry = lNode.x - treeHeight / 2;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
return h;
|
|
83
|
-
}
|
|
84
|
-
export function MindMapTree({ data, selectedPath, onSelect, onToggle, branchColors, width, height }) {
|
|
85
|
-
const svgRef = useRef(null);
|
|
86
|
-
const gRef = useRef(null);
|
|
87
|
-
const zoomRef = useRef(null);
|
|
88
|
-
// Split into left/right halves
|
|
89
|
-
const { leftNodes, rightNodes } = useMemo(() => {
|
|
90
|
-
const split = splitTree(data);
|
|
91
|
-
const leftChildren = [];
|
|
92
|
-
const rightChildren = [];
|
|
93
|
-
for (const c of split.children) {
|
|
94
|
-
if (c._side === 'left')
|
|
95
|
-
leftChildren.push(c);
|
|
96
|
-
else
|
|
97
|
-
rightChildren.push(c);
|
|
98
|
-
}
|
|
99
|
-
const left = layoutHalf(data, leftChildren, 'left', height);
|
|
100
|
-
const right = layoutHalf(data, rightChildren, 'right', height);
|
|
101
|
-
return {
|
|
102
|
-
leftNodes: left ? left.descendants().slice(1) : [],
|
|
103
|
-
rightNodes: right ? right.descendants().slice(1) : [],
|
|
104
|
-
};
|
|
105
|
-
}, [data, height]);
|
|
106
|
-
const allNodes = useMemo(() => [...leftNodes, ...rightNodes], [leftNodes, rightNodes]);
|
|
107
|
-
// Build links from parent→child coords
|
|
108
|
-
const links = useMemo(() => {
|
|
109
|
-
const result = [];
|
|
110
|
-
for (const node of allNodes) {
|
|
111
|
-
const parent = node.parent;
|
|
112
|
-
if (!parent)
|
|
113
|
-
continue;
|
|
114
|
-
const sx = parent.depth === 0 ? 0 : parent._rx;
|
|
115
|
-
const sy = parent.depth === 0 ? 0 : parent._ry;
|
|
116
|
-
const tx = node._rx;
|
|
117
|
-
const ty = node._ry;
|
|
118
|
-
const color = branchColors.get(node.data.path) ?? 'var(--text-3)';
|
|
119
|
-
const strokeWidth = Math.max(1.5, 3.5 - node.depth * 0.6);
|
|
120
|
-
result.push({
|
|
121
|
-
key: `${parent.data.path}->${node.data.path}`,
|
|
122
|
-
path: linkPath(sx, sy, tx, ty),
|
|
123
|
-
color,
|
|
124
|
-
width: strokeWidth,
|
|
125
|
-
source: parent,
|
|
126
|
-
target: node,
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
return result;
|
|
130
|
-
}, [allNodes, branchColors]);
|
|
131
|
-
// Zoom
|
|
132
|
-
useEffect(() => {
|
|
133
|
-
if (!svgRef.current || !gRef.current)
|
|
134
|
-
return;
|
|
135
|
-
const svg = select(svgRef.current);
|
|
136
|
-
const g = select(gRef.current);
|
|
137
|
-
const zoomBehavior = d3zoom()
|
|
138
|
-
.scaleExtent([0.1, 4])
|
|
139
|
-
.on('zoom', event => {
|
|
140
|
-
g.attr('transform', event.transform.toString());
|
|
141
|
-
});
|
|
142
|
-
svg.call(zoomBehavior);
|
|
143
|
-
const initialTransform = zoomIdentity.translate(width / 2, height / 2).scale(0.85);
|
|
144
|
-
svg.call(zoomBehavior.transform, initialTransform);
|
|
145
|
-
zoomRef.current = zoomBehavior;
|
|
146
|
-
return () => { svg.on('.zoom', null); };
|
|
147
|
-
}, [width, height]);
|
|
148
|
-
// Fit view
|
|
149
|
-
const fitView = useCallback(() => {
|
|
150
|
-
if (!svgRef.current || !zoomRef.current || !gRef.current)
|
|
151
|
-
return;
|
|
152
|
-
const svg = select(svgRef.current);
|
|
153
|
-
const bounds = gRef.current.getBBox();
|
|
154
|
-
if (!bounds.width || !bounds.height)
|
|
155
|
-
return;
|
|
156
|
-
const pad = 60;
|
|
157
|
-
const scale = Math.min((width - pad * 2) / bounds.width, (height - pad * 2) / bounds.height, 1.5);
|
|
158
|
-
const tx = width / 2 - (bounds.x + bounds.width / 2) * scale;
|
|
159
|
-
const ty = height / 2 - (bounds.y + bounds.height / 2) * scale;
|
|
160
|
-
svg.transition().duration(400).call(zoomRef.current.transform, zoomIdentity.translate(tx, ty).scale(scale));
|
|
161
|
-
}, [width, height]);
|
|
162
|
-
// Auto-fit on data change
|
|
163
|
-
useEffect(() => {
|
|
164
|
-
const t = setTimeout(fitView, 80);
|
|
165
|
-
return () => clearTimeout(t);
|
|
166
|
-
}, [data, fitView]);
|
|
167
|
-
const rootName = data.name === '/' ? '/' : data.name;
|
|
168
|
-
const rootColor = branchColors.get(data.path) ?? 'var(--text)';
|
|
169
|
-
return (_jsxs("div", { className: "mm-tree-wrap", children: [_jsx("div", { className: "mm-toolbar", children: _jsx("button", { className: "mm-btn", onClick: fitView, title: "Fit view", children: _jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", children: _jsx("path", { d: "M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" }) }) }) }), _jsx("svg", { ref: svgRef, width: width, height: height, className: "mm-svg", children: _jsxs("g", { ref: gRef, children: [links.map(l => (_jsx("path", { d: l.path, fill: "none", stroke: l.color, strokeWidth: l.width, strokeOpacity: 0.7, strokeLinecap: "round", className: "mm-link" }, l.key))), _jsxs("g", { className: `mm-node mm-root${selectedPath === data.path ? ' mm-node-selected' : ''}`, onClick: () => onSelect(data.path), onDoubleClick: () => onToggle(data.path), children: [_jsx("rect", { x: -textWidth(rootName, 16) / 2 - 24, y: -20, width: textWidth(rootName, 16) + 48, height: 40, rx: 20, className: "mm-root-bg" }), _jsx("text", { textAnchor: "middle", dominantBaseline: "central", className: "mm-root-label", children: rootName })] }), allNodes.map(node => {
|
|
170
|
-
const d = node.data;
|
|
171
|
-
const x = node._rx;
|
|
172
|
-
const y = node._ry;
|
|
173
|
-
const color = branchColors.get(d.path) ?? 'var(--text-2)';
|
|
174
|
-
const isLeft = d._side === 'left';
|
|
175
|
-
const isSelected = selectedPath === d.path;
|
|
176
|
-
const icon = getIcon(d.type);
|
|
177
|
-
const hasChildren = d.childCount > 0;
|
|
178
|
-
const label = d.name;
|
|
179
|
-
// Type badge (short)
|
|
180
|
-
const shortType = d.type.includes('.') ? d.type.split('.').pop() : '';
|
|
181
|
-
return (_jsxs("g", { transform: `translate(${x},${y})`, className: `mm-node${isSelected ? ' mm-node-selected' : ''}`, onClick: () => onSelect(d.path), onDoubleClick: () => onToggle(d.path), children: [_jsx("rect", { x: isLeft ? -textWidth(label, 13) - 30 : -10, y: -14, width: textWidth(label, 13) + 50, height: 28, fill: "transparent", className: "mm-hit" }), isSelected && (_jsx("rect", { x: isLeft ? -textWidth(label, 13) - 26 : -6, y: -12, width: textWidth(label, 13) + 42, height: 24, rx: 12, className: "mm-select-bg", fill: color, fillOpacity: 0.1 })), _jsx("circle", { cx: 0, cy: 0, r: hasChildren && !d.expanded ? 4 : 3, fill: color, className: "mm-dot" }), icon && (_jsx("g", { transform: `translate(${isLeft ? -22 : 8}, -8) scale(0.7)`, children: _jsx("path", { d: icon, fill: color, fillOpacity: 0.6 }) })), _jsx("text", { x: isLeft ? -12 : (icon ? 24 : 12), textAnchor: isLeft ? 'end' : 'start', dominantBaseline: "central", className: "mm-label", fill: color, children: label }), shortType && node.depth <= 2 && (_jsx("text", { x: isLeft ? -12 - textWidth(label, 13) - 8 : (icon ? 24 : 12) + textWidth(label, 13) + 8, textAnchor: isLeft ? 'end' : 'start', dominantBaseline: "central", className: "mm-type-tag", fill: color, children: shortType })), hasChildren && !d.expanded && (_jsxs("g", { transform: `translate(${isLeft ? 8 : -8 + (icon ? 24 : 12) + textWidth(label, 13) + (shortType && node.depth <= 2 ? textWidth(shortType, 10) + 16 : 8)}, 0)`, className: "mm-count-badge", onClick: e => { e.stopPropagation(); onToggle(d.path); }, children: [_jsx("circle", { r: 8, fill: color, fillOpacity: 0.15 }), _jsx("text", { textAnchor: "middle", dominantBaseline: "central", fill: color, fontSize: "9", fontWeight: "600", children: d.childCount })] }))] }, d.path));
|
|
182
|
-
})] }) })] }));
|
|
183
|
-
}
|
|
184
|
-
//# sourceMappingURL=radial-tree.js.map
|