@musashishao/folderforge 1.2.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.
Files changed (64) hide show
  1. package/README.md +181 -0
  2. package/dist/adapters/child-mcp/client.js +114 -0
  3. package/dist/adapters/child-mcp/registry.js +66 -0
  4. package/dist/audit/audit-log.js +45 -0
  5. package/dist/audit/event-types.js +1 -0
  6. package/dist/core/config.js +211 -0
  7. package/dist/core/container.js +51 -0
  8. package/dist/core/errors.js +37 -0
  9. package/dist/core/logger.js +8 -0
  10. package/dist/core/types.js +4 -0
  11. package/dist/dashboard/server.js +191 -0
  12. package/dist/lsp/protocol.js +116 -0
  13. package/dist/main.js +190 -0
  14. package/dist/managers/db-manager.js +161 -0
  15. package/dist/managers/lsp-manager.js +269 -0
  16. package/dist/managers/process-manager.js +140 -0
  17. package/dist/policy/approvals.js +143 -0
  18. package/dist/policy/command-policy.js +99 -0
  19. package/dist/policy/glob-match.js +61 -0
  20. package/dist/policy/path-policy.js +73 -0
  21. package/dist/policy/policy-engine.js +156 -0
  22. package/dist/policy/rate-limiter.js +96 -0
  23. package/dist/policy/risk.js +112 -0
  24. package/dist/policy/secret-policy.js +132 -0
  25. package/dist/server/mcp-server.js +144 -0
  26. package/dist/server/transports/http.js +133 -0
  27. package/dist/server/transports/stdio.js +14 -0
  28. package/dist/tools/adapter-tools.js +62 -0
  29. package/dist/tools/browser-tools.js +76 -0
  30. package/dist/tools/build-tools.js +78 -0
  31. package/dist/tools/code-tools.js +250 -0
  32. package/dist/tools/coverage-tools.js +135 -0
  33. package/dist/tools/db-tools.js +130 -0
  34. package/dist/tools/diff-util.js +45 -0
  35. package/dist/tools/error-parser.js +57 -0
  36. package/dist/tools/file-tools.js +319 -0
  37. package/dist/tools/format-tools.js +118 -0
  38. package/dist/tools/git-tools.js +371 -0
  39. package/dist/tools/index.js +63 -0
  40. package/dist/tools/memory-tools.js +54 -0
  41. package/dist/tools/output-schemas.js +100 -0
  42. package/dist/tools/pagination.js +92 -0
  43. package/dist/tools/pkg-tools.js +260 -0
  44. package/dist/tools/process-tools.js +128 -0
  45. package/dist/tools/registry.js +194 -0
  46. package/dist/tools/schema-lock.js +152 -0
  47. package/dist/tools/search-tools.js +176 -0
  48. package/dist/tools/security-tools.js +147 -0
  49. package/dist/tools/terminal-tools.js +57 -0
  50. package/dist/tools/workspace-tools.js +186 -0
  51. package/dist/workspace/memory-store.js +67 -0
  52. package/dist/workspace/onboarding.js +46 -0
  53. package/dist/workspace/project-detector.js +95 -0
  54. package/dist/workspace/workspace-manager.js +106 -0
  55. package/docs/adapters.md +76 -0
  56. package/docs/architecture.md +66 -0
  57. package/docs/roadmap.md +172 -0
  58. package/docs/security.md +94 -0
  59. package/docs/tools.md +129 -0
  60. package/examples/claude-desktop.json +18 -0
  61. package/examples/codex.toml +18 -0
  62. package/examples/config.basic.yaml +37 -0
  63. package/examples/config.full.yaml +120 -0
  64. package/package.json +74 -0
@@ -0,0 +1,112 @@
1
+ export const RISK_ORDER = {
2
+ LOW: 0,
3
+ MEDIUM: 1,
4
+ HIGH: 2,
5
+ CRITICAL: 3,
6
+ };
7
+ export function maxRisk(a, b) {
8
+ return RISK_ORDER[a] >= RISK_ORDER[b] ? a : b;
9
+ }
10
+ /**
11
+ * Default risk classification per tool name (section 8 of the spec).
12
+ */
13
+ export const TOOL_RISK = {
14
+ // LOW
15
+ file_read: 'LOW',
16
+ file_read_many: 'LOW',
17
+ search_files: 'LOW',
18
+ search_text: 'LOW',
19
+ search_ast: 'LOW',
20
+ git_status: 'LOW',
21
+ git_diff: 'LOW',
22
+ git_log: 'LOW',
23
+ git_show: 'LOW',
24
+ git_blame: 'LOW',
25
+ git_branch: 'LOW',
26
+ git_fetch: 'MEDIUM',
27
+ git_stash: 'MEDIUM',
28
+ list_directory: 'LOW',
29
+ run_test: 'LOW',
30
+ run_lint: 'LOW',
31
+ run_typecheck: 'LOW',
32
+ run_coverage: 'LOW',
33
+ pkg_list: 'LOW',
34
+ pkg_outdated: 'LOW',
35
+ pkg_audit: 'LOW',
36
+ format_check: 'LOW',
37
+ project_detect_commands: 'LOW',
38
+ workspace_status: 'LOW',
39
+ workspace_list: 'LOW',
40
+ workspace_health: 'LOW',
41
+ workspace_route: 'LOW',
42
+ policy_get: 'LOW',
43
+ policy_explain: 'LOW',
44
+ policy_ratelimits: 'LOW',
45
+ audit_recent: 'LOW',
46
+ memory_list: 'LOW',
47
+ memory_read: 'LOW',
48
+ code_symbols_overview: 'LOW',
49
+ code_find_symbol: 'LOW',
50
+ code_find_references: 'LOW',
51
+ code_find_definition: 'LOW',
52
+ code_find_implementations: 'LOW',
53
+ code_diagnostics: 'LOW',
54
+ browser_snapshot: 'LOW',
55
+ browser_console: 'LOW',
56
+ browser_network: 'LOW',
57
+ db_list_connections: 'LOW',
58
+ db_list_tables: 'LOW',
59
+ db_describe_table: 'LOW',
60
+ db_query_readonly: 'LOW',
61
+ db_explain: 'LOW',
62
+ secret_scan: 'LOW',
63
+ approval_status: 'LOW',
64
+ // MEDIUM
65
+ file_write: 'MEDIUM',
66
+ file_patch: 'MEDIUM',
67
+ file_edit_block: 'MEDIUM',
68
+ file_move: 'MEDIUM',
69
+ file_copy: 'MEDIUM',
70
+ git_pull: 'HIGH',
71
+ workspace_activate: 'MEDIUM',
72
+ workspace_switch: 'MEDIUM',
73
+ workspace_deactivate: 'MEDIUM',
74
+ workspace_onboard: 'MEDIUM',
75
+ git_add: 'MEDIUM',
76
+ git_checkout: 'MEDIUM',
77
+ run_build: 'MEDIUM',
78
+ pkg_run: 'MEDIUM',
79
+ format_apply: 'MEDIUM',
80
+ process_start: 'MEDIUM',
81
+ process_read: 'LOW',
82
+ process_tail: 'LOW',
83
+ process_write: 'MEDIUM',
84
+ process_stop: 'MEDIUM',
85
+ process_list: 'LOW',
86
+ memory_write: 'MEDIUM',
87
+ memory_update: 'MEDIUM',
88
+ code_replace_symbol_body: 'MEDIUM',
89
+ code_insert_before_symbol: 'MEDIUM',
90
+ code_insert_after_symbol: 'MEDIUM',
91
+ code_rename_symbol: 'MEDIUM',
92
+ browser_open: 'MEDIUM',
93
+ browser_click: 'MEDIUM',
94
+ browser_type: 'MEDIUM',
95
+ browser_screenshot: 'MEDIUM',
96
+ browser_close: 'MEDIUM',
97
+ policy_set_mode: 'MEDIUM',
98
+ db_connect: 'MEDIUM',
99
+ shell_exec: 'MEDIUM', // re-classified per command at runtime
100
+ // HIGH
101
+ file_delete: 'HIGH',
102
+ git_commit: 'HIGH',
103
+ process_kill: 'HIGH',
104
+ db_run_migration: 'HIGH',
105
+ db_write: 'HIGH',
106
+ pkg_add: 'HIGH',
107
+ pkg_remove: 'HIGH',
108
+ // CRITICAL
109
+ git_push: 'CRITICAL',
110
+ git_reset: 'CRITICAL',
111
+ browser_eval: 'HIGH',
112
+ };
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Secret detection and redaction.
3
+ */
4
+ const RULES = [
5
+ { name: 'OpenAI key', re: /\bsk-[A-Za-z0-9]{20,}\b/g },
6
+ { name: 'Anthropic key', re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
7
+ { name: 'AWS access key id', re: /\bAKIA[0-9A-Z]{16}\b/g },
8
+ { name: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g },
9
+ { name: 'Google API key', re: /\bAIza[0-9A-Za-z_-]{35}\b/g },
10
+ { name: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g },
11
+ { name: 'Private key block', re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g },
12
+ { name: 'Generic assignment', re: /\b(?:password|passwd|secret|token|api[_-]?key|access[_-]?key)\s*[=:]\s*['"]?[^\s'"]{6,}/gi },
13
+ { name: 'Env secret', re: /\b(?:OPENAI_API_KEY|ANTHROPIC_API_KEY|AWS_SECRET_ACCESS_KEY|AWS_ACCESS_KEY_ID|GITHUB_TOKEN)\s*=\s*\S+/g },
14
+ ];
15
+ const NAMED_ENV = [
16
+ 'OPENAI_API_KEY',
17
+ 'ANTHROPIC_API_KEY',
18
+ 'AWS_ACCESS_KEY_ID',
19
+ 'AWS_SECRET_ACCESS_KEY',
20
+ 'GITHUB_TOKEN',
21
+ 'GOOGLE_API_KEY',
22
+ 'DATABASE_URL',
23
+ ];
24
+ const DEFAULT_SCAN_CONFIG = {
25
+ entropyEnabled: true,
26
+ minEntropy: 4.0,
27
+ minLength: 20,
28
+ };
29
+ /**
30
+ * Shannon entropy in bits per character for a string. High values (> ~4.0)
31
+ * indicate random-looking content typical of keys and tokens.
32
+ */
33
+ export function shannonEntropy(s) {
34
+ if (!s.length)
35
+ return 0;
36
+ const counts = new Map();
37
+ for (const ch of s)
38
+ counts.set(ch, (counts.get(ch) ?? 0) + 1);
39
+ let h = 0;
40
+ for (const c of counts.values()) {
41
+ const p = c / s.length;
42
+ h -= p * Math.log2(p);
43
+ }
44
+ return h;
45
+ }
46
+ // Candidate tokens for entropy scanning: long runs of base64/hex/key-ish chars.
47
+ const TOKEN_RE = /[A-Za-z0-9+/_-]{16,}/g;
48
+ // Tokens that are clearly not secrets (all one case word, pure numbers, hashes
49
+ // of known kinds are still flagged - better safe). We skip obvious noise.
50
+ const LOW_SIGNAL_RE = /^[0-9]+$/;
51
+ export class SecretPolicy {
52
+ scanConfig;
53
+ constructor(scanConfig = DEFAULT_SCAN_CONFIG) {
54
+ this.scanConfig = scanConfig;
55
+ }
56
+ redact(text) {
57
+ let out = text;
58
+ for (const rule of RULES) {
59
+ out = out.replace(rule.re, (m) => {
60
+ if (rule.name === 'Generic assignment') {
61
+ const eq = m.search(/[=:]/);
62
+ return `${m.slice(0, eq + 1)} [REDACTED]`;
63
+ }
64
+ if (rule.name === 'Env secret') {
65
+ const eq = m.indexOf('=');
66
+ return `${m.slice(0, eq + 1)}[REDACTED]`;
67
+ }
68
+ return '[REDACTED]';
69
+ });
70
+ }
71
+ return out;
72
+ }
73
+ redactEnv(env) {
74
+ const out = {};
75
+ for (const [k, v] of Object.entries(env)) {
76
+ if (v === undefined)
77
+ continue;
78
+ if (NAMED_ENV.includes(k) || /key|secret|token|password|passwd/i.test(k)) {
79
+ out[k] = '[REDACTED]';
80
+ }
81
+ else {
82
+ out[k] = v;
83
+ }
84
+ }
85
+ return out;
86
+ }
87
+ scan(text) {
88
+ const findings = [];
89
+ const lines = text.split('\n');
90
+ lines.forEach((line, i) => {
91
+ for (const rule of RULES) {
92
+ rule.re.lastIndex = 0;
93
+ const m = rule.re.exec(line);
94
+ if (m) {
95
+ findings.push({
96
+ rule: rule.name,
97
+ preview: m[0].slice(0, 12) + '...',
98
+ line: i + 1,
99
+ });
100
+ }
101
+ }
102
+ // Entropy pass: flag high-entropy tokens that no rule matched. This catches
103
+ // bespoke/unknown key formats. Tokens already covered by a regex finding on
104
+ // this line are skipped to avoid double-reporting.
105
+ if (this.scanConfig.entropyEnabled) {
106
+ const matchedOnLine = findings.filter((f) => f.line === i + 1 && f.rule !== 'high entropy');
107
+ TOKEN_RE.lastIndex = 0;
108
+ let tok;
109
+ while ((tok = TOKEN_RE.exec(line)) !== null) {
110
+ const value = tok[0];
111
+ if (value.length < this.scanConfig.minLength)
112
+ continue;
113
+ if (LOW_SIGNAL_RE.test(value))
114
+ continue;
115
+ const alreadyFlagged = matchedOnLine.some((f) => value.startsWith(f.preview.replace(/\.\.\.$/, '')));
116
+ if (alreadyFlagged)
117
+ continue;
118
+ const entropy = shannonEntropy(value);
119
+ if (entropy >= this.scanConfig.minEntropy) {
120
+ findings.push({
121
+ rule: 'high entropy',
122
+ preview: value.slice(0, 12) + '...',
123
+ line: i + 1,
124
+ entropy: Math.round(entropy * 100) / 100,
125
+ });
126
+ }
127
+ }
128
+ }
129
+ });
130
+ return findings;
131
+ }
132
+ }
@@ -0,0 +1,144 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
3
+ import { logger } from '../core/logger.js';
4
+ /**
5
+ * Build an MCP {@link Server} backed by the FolderForge {@link ToolRegistry}.
6
+ *
7
+ * The server exposes exactly two capabilities:
8
+ * - `tools/list` -> reads {@link ToolRegistry.listActive} (curated/active subset)
9
+ * - `tools/call` -> delegates to {@link ToolRegistry.call} (policy + audit pipeline)
10
+ *
11
+ * `tools/list` additionally advertises, when present:
12
+ * - `outputSchema` (MCP structured tool output, 2025-06-18)
13
+ * - `annotations` (readOnly/destructive/idempotent hints, derived from
14
+ * the frozen mutates/risk contract in defineTool)
15
+ *
16
+ * Transport binding (stdio / http) is handled separately in `server/transports/*`.
17
+ */
18
+ export function createMcpServer(registry, info) {
19
+ const roots = info.roots ?? [];
20
+ // NOTE on `roots`: in MCP, `roots` is a *client* capability (the client tells
21
+ // the server which directories are in scope). A server cannot declare it.
22
+ // FolderForge instead surfaces its own filesystem scope to the client through
23
+ // the server `instructions` string below (and via the workspace_* tools), so
24
+ // the agent can discover the allowed directories without us mis-declaring a
25
+ // capability we don't own. This satisfies roadmap P5 truthfully.
26
+ const rootsLine = roots.length
27
+ ? `\n\nWorkspace roots (allowed directories): ${roots.join(', ')}.`
28
+ : '';
29
+ const server = new Server({ name: info.name, version: info.version }, {
30
+ capabilities: { tools: {} },
31
+ instructions: 'FolderForge: local development control plane. All tools run through a ' +
32
+ 'policy + audit pipeline; tool annotations (readOnlyHint/destructiveHint) ' +
33
+ 'are hints only.' +
34
+ rootsLine,
35
+ });
36
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
37
+ const tools = registry.listActive().map((t) => {
38
+ const tool = {
39
+ name: t.name,
40
+ description: t.description,
41
+ inputSchema: toJsonSchema(t.inputSchema),
42
+ };
43
+ // Advertise structured output schema (MCP outputSchema) when a tool
44
+ // declares one. Clients use it to validate `structuredContent`.
45
+ if (t.outputSchema) {
46
+ tool.outputSchema = toJsonSchema(t.outputSchema);
47
+ }
48
+ // Advertise behaviour hints. Derived from mutates/risk in defineTool;
49
+ // hints only - never a security boundary.
50
+ if (t.annotations) {
51
+ tool.annotations = t.annotations;
52
+ }
53
+ return tool;
54
+ });
55
+ return { tools };
56
+ });
57
+ server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
58
+ const { name, arguments: args } = request.params;
59
+ // Wire MCP protocol features into the tool pipeline via a per-call control
60
+ // object (roadmap P4/P6/P8). All three are optional: a long-running handler
61
+ // may report progress, observe cancellation, or elicit input, while a
62
+ // simple handler ignores `control` entirely. None of this touches the
63
+ // frozen tool schema, so the schema-lock is unaffected.
64
+ const progressToken = request.params._meta?.progressToken;
65
+ // P8 - elicitation adapter: normalize the SDK's elicitInput result into the
66
+ // project's ElicitResult shape (narrowing `action` to our union). Present
67
+ // only when the client advertised the `elicitation` capability; otherwise
68
+ // omitted entirely so handlers fall back to non-interactive defaults.
69
+ const elicit = server.getClientCapabilities()
70
+ ?.elicitation
71
+ ? async (params) => {
72
+ const r = await server.elicitInput(params);
73
+ const action = r.action;
74
+ return r.content !== undefined ? { action, content: r.content } : { action };
75
+ }
76
+ : undefined;
77
+ // P4 - progress: only emit when the client opted in by sending a
78
+ // progressToken in the request _meta. Mirrors the SDK contract.
79
+ const reportProgress = progressToken === undefined
80
+ ? undefined
81
+ : async (progress, total, message) => {
82
+ await extra.sendNotification({
83
+ method: 'notifications/progress',
84
+ params: { progressToken, progress, total, message },
85
+ });
86
+ };
87
+ // Build the control object with conditional spreads: under
88
+ // exactOptionalPropertyTypes an optional field cannot be assigned
89
+ // `undefined` explicitly, so we omit absent capabilities entirely.
90
+ const control = {
91
+ // P6 - cancellation: the SDK aborts `extra.signal` on a notifications/
92
+ // cancelled for this request id. Handlers long-poll against it the same
93
+ // way ProcessManager.readUntil waits on its own waiters.
94
+ signal: extra.signal,
95
+ ...(reportProgress !== undefined ? { reportProgress } : {}),
96
+ ...(elicit !== undefined ? { elicitInput: elicit } : {}),
97
+ };
98
+ const result = await registry.call(name, (args ?? {}), control);
99
+ const tool = registry.get(name);
100
+ return toCallToolResult(result, Boolean(tool?.outputSchema));
101
+ });
102
+ server.onerror = (err) => {
103
+ logger.error({ err: err instanceof Error ? err.message : String(err) }, 'MCP server error');
104
+ };
105
+ return server;
106
+ }
107
+ /**
108
+ * Tool input schemas are stored as plain JSON-schema objects. The MCP SDK
109
+ * requires the top-level `type` to be `"object"`; normalize defensively.
110
+ */
111
+ function toJsonSchema(schema) {
112
+ const base = schema && typeof schema === 'object' ? schema : {};
113
+ return { type: 'object', ...base };
114
+ }
115
+ /**
116
+ * Convert a FolderForge {@link ToolResult} into an MCP `tools/call` result.
117
+ *
118
+ * Exported for unit testing of the structuredContent mirroring contract.
119
+ */
120
+ export function toCallToolResult(result, hasOutputSchema = false) {
121
+ if (!result.ok) {
122
+ const text = result.approvalId
123
+ ? `${result.error ?? 'Approval required'}\n(approvalId=${result.approvalId})`
124
+ : result.error ?? 'Tool call failed';
125
+ return { content: [{ type: 'text', text }], isError: true };
126
+ }
127
+ const payload = {};
128
+ if (result.data !== undefined)
129
+ payload.data = result.data;
130
+ if (result.diff !== undefined)
131
+ payload.diff = result.diff;
132
+ const text = result.diff && result.data === undefined
133
+ ? result.diff
134
+ : JSON.stringify(Object.keys(payload).length ? payload : { ok: true }, null, 2);
135
+ const out = { content: [{ type: 'text', text }] };
136
+ // When a tool declares an outputSchema, also return machine-readable
137
+ // structuredContent so spec-aware clients can consume typed output without
138
+ // re-parsing the text block (MCP 2025-06-18 structured tool output).
139
+ if (hasOutputSchema && result.data !== undefined && result.data !== null) {
140
+ out.structuredContent =
141
+ result.data;
142
+ }
143
+ return out;
144
+ }
@@ -0,0 +1,133 @@
1
+ import { createServer } from 'node:http';
2
+ import { randomUUID, timingSafeEqual } from 'node:crypto';
3
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
4
+ import { logger } from '../../core/logger.js';
5
+ /** True when the bind host is loopback-only and therefore safe without a token. */
6
+ export function isLoopbackHost(host) {
7
+ return host === '127.0.0.1' || host === '::1' || host === 'localhost';
8
+ }
9
+ /** Constant-time string comparison that tolerates length differences. */
10
+ export function timingSafeEqualStr(a, b) {
11
+ const ab = Buffer.from(a);
12
+ const bb = Buffer.from(b);
13
+ if (ab.length !== bb.length) {
14
+ // Still compare against self to keep timing roughly constant.
15
+ timingSafeEqual(ab, ab);
16
+ return false;
17
+ }
18
+ return timingSafeEqual(ab, bb);
19
+ }
20
+ /** Extract a bearer token from the Authorization header. */
21
+ export function extractBearer(req) {
22
+ const header = req.headers['authorization'];
23
+ if (typeof header === 'string' && header.startsWith('Bearer ')) {
24
+ return header.slice('Bearer '.length).trim();
25
+ }
26
+ return undefined;
27
+ }
28
+ /**
29
+ * Resolve the CORS origin header value for a request, or null to omit it.
30
+ * `['*']` echoes any origin (so credentials can still work); a concrete list
31
+ * echoes only matching origins.
32
+ */
33
+ export function resolveCorsOrigin(requestOrigin, allowed) {
34
+ if (!allowed || allowed.length === 0)
35
+ return null;
36
+ if (allowed.includes('*'))
37
+ return requestOrigin ?? '*';
38
+ if (requestOrigin && allowed.includes(requestOrigin))
39
+ return requestOrigin;
40
+ return null;
41
+ }
42
+ /**
43
+ * Bind the MCP server to a hardened Streamable HTTP transport.
44
+ *
45
+ * Hardening over the bare transport:
46
+ * - Bearer-token auth (constant-time) when a token is configured.
47
+ * - CORS handling with an explicit allowlist + preflight support.
48
+ * - Idle session expiry: the underlying transport is recreated after
49
+ * `sessionTtlMs` of inactivity so stale sessions don't linger.
50
+ */
51
+ export async function startHttpTransport(server, opts) {
52
+ const mcpPath = opts.path ?? '/mcp';
53
+ const ttl = opts.sessionTtlMs ?? 30 * 60 * 1000; // 30 min default
54
+ const requireAuth = Boolean(opts.token);
55
+ if (!isLoopbackHost(opts.host) && !opts.token) {
56
+ throw new Error('HTTP transport bound to a non-loopback host requires server.http.token');
57
+ }
58
+ let transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
59
+ await server.connect(transport);
60
+ let lastActivity = Date.now();
61
+ // Recreate the transport (and reconnect the server) when it has been idle past
62
+ // the TTL, expiring any stale session id.
63
+ const refreshIfIdle = async () => {
64
+ if (Date.now() - lastActivity > ttl) {
65
+ try {
66
+ await transport.close?.();
67
+ }
68
+ catch {
69
+ // ignore close errors
70
+ }
71
+ transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
72
+ await server.connect(transport);
73
+ logger.info({ ttlMs: ttl }, 'HTTP MCP session expired after idle TTL; rotated transport');
74
+ }
75
+ lastActivity = Date.now();
76
+ };
77
+ const applyCors = (req, res) => {
78
+ const origin = resolveCorsOrigin(req.headers.origin, opts.corsOrigins);
79
+ if (origin) {
80
+ res.setHeader('access-control-allow-origin', origin);
81
+ res.setHeader('vary', 'Origin');
82
+ res.setHeader('access-control-allow-methods', 'GET, POST, DELETE, OPTIONS');
83
+ res.setHeader('access-control-allow-headers', 'authorization, content-type, mcp-session-id');
84
+ }
85
+ };
86
+ const http = createServer((req, res) => {
87
+ const url = req.url ?? '/';
88
+ applyCors(req, res);
89
+ // CORS preflight.
90
+ if (req.method === 'OPTIONS') {
91
+ res.writeHead(204);
92
+ res.end();
93
+ return;
94
+ }
95
+ if (req.method === 'GET' && url === '/healthz') {
96
+ res.writeHead(200, { 'content-type': 'application/json' });
97
+ res.end(JSON.stringify({ ok: true }));
98
+ return;
99
+ }
100
+ if (url === mcpPath || url.startsWith(`${mcpPath}?`)) {
101
+ if (requireAuth) {
102
+ const provided = extractBearer(req);
103
+ if (!provided || !timingSafeEqualStr(provided, opts.token)) {
104
+ res.writeHead(401, {
105
+ 'content-type': 'application/json',
106
+ 'www-authenticate': 'Bearer realm="folderforge-mcp"',
107
+ });
108
+ res.end(JSON.stringify({ error: 'unauthorized', message: 'Valid bearer token required.' }));
109
+ return;
110
+ }
111
+ }
112
+ void refreshIfIdle()
113
+ .then(() => transport.handleRequest(req, res))
114
+ .catch((err) => {
115
+ logger.error({ err: String(err) }, 'HTTP MCP request failed');
116
+ if (!res.headersSent) {
117
+ res.writeHead(500, { 'content-type': 'application/json' });
118
+ res.end(JSON.stringify({ error: 'internal_error' }));
119
+ }
120
+ });
121
+ return;
122
+ }
123
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
124
+ res.end('Not found');
125
+ });
126
+ await new Promise((resolveListen) => {
127
+ http.listen(opts.port, opts.host, () => {
128
+ logger.info({ host: opts.host, port: opts.port, path: mcpPath, authRequired: requireAuth, sessionTtlMs: ttl }, 'MCP HTTP transport listening');
129
+ resolveListen();
130
+ });
131
+ });
132
+ return http;
133
+ }
@@ -0,0 +1,14 @@
1
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
2
+ import { logger } from '../../core/logger.js';
3
+ /**
4
+ * Bind the MCP server to the stdio transport.
5
+ *
6
+ * stdin/stdout carry the JSON-RPC channel, so logs must go to stderr only
7
+ * (see core/logger.ts). Returns once the transport is connected.
8
+ */
9
+ export async function startStdioTransport(server) {
10
+ const transport = new StdioServerTransport();
11
+ await server.connect(transport);
12
+ logger.info('MCP stdio transport connected');
13
+ return transport;
14
+ }
@@ -0,0 +1,62 @@
1
+ import { defineTool } from './registry.js';
2
+ import { logger } from '../core/logger.js';
3
+ /** Adapters we expose, in a stable order, with their namespace prefix. */
4
+ const ADAPTER_NAMES = ['serena', 'playwright', 'desktopCommander'];
5
+ /** Separator between an adapter namespace and the child tool name. */
6
+ export const NS_SEP = '__';
7
+ /**
8
+ * Build the namespaced tool name for a child tool, e.g. `serena__find_symbol`.
9
+ */
10
+ export function namespacedName(adapter, childTool) {
11
+ return `${adapter}${NS_SEP}${childTool}`;
12
+ }
13
+ /**
14
+ * Discover the tools exposed by every enabled child MCP adapter and wrap each
15
+ * one as a native FolderForge {@link ToolDefinition}. The wrapper:
16
+ * - prefixes the name with the adapter namespace (`serena__find_symbol`)
17
+ * - lazily starts the child process on first call (via `adapters.ensure`)
18
+ * - routes the call through the same policy + audit pipeline as native tools
19
+ *
20
+ * Child tools are treated as MEDIUM risk and `mutates: true` by default so that
21
+ * policy mode (readonly/safe) and the approval list still gate them. Discovery
22
+ * failures for one adapter never block the others.
23
+ */
24
+ export async function buildAdapterTools(container) {
25
+ const tools = [];
26
+ for (const name of ADAPTER_NAMES) {
27
+ if (!container.adapters.isEnabled(name))
28
+ continue;
29
+ let childTools;
30
+ try {
31
+ const client = await container.adapters.ensure(name);
32
+ childTools = (await client.listTools());
33
+ }
34
+ catch (err) {
35
+ logger.warn({ adapter: name, err: String(err) }, 'Skipping adapter; tool discovery failed');
36
+ continue;
37
+ }
38
+ for (const child of childTools) {
39
+ const toolName = namespacedName(name, child.name);
40
+ tools.push(defineTool({
41
+ name: toolName,
42
+ description: child.description ?? `${name} tool: ${child.name}`,
43
+ inputSchema: child.inputSchema ?? { type: 'object' },
44
+ group: `adapter:${name}`,
45
+ mutates: true,
46
+ risk: 'MEDIUM',
47
+ handler: async (args) => {
48
+ try {
49
+ const client = await container.adapters.ensure(name);
50
+ const raw = await client.callTool(child.name, args);
51
+ return { ok: true, data: raw };
52
+ }
53
+ catch (err) {
54
+ return { ok: false, error: `${toolName} failed: ${String(err)}` };
55
+ }
56
+ },
57
+ }));
58
+ }
59
+ logger.info({ adapter: name, count: childTools.length }, 'Registered adapter tools');
60
+ }
61
+ return tools;
62
+ }
@@ -0,0 +1,76 @@
1
+ import { defineTool } from './registry.js';
2
+ const PW_MAP = {
3
+ browser_open: 'browser_navigate',
4
+ browser_snapshot: 'browser_snapshot',
5
+ browser_click: 'browser_click',
6
+ browser_type: 'browser_type',
7
+ browser_console: 'browser_console_messages',
8
+ browser_network: 'browser_network_requests',
9
+ browser_screenshot: 'browser_take_screenshot',
10
+ browser_close: 'browser_close',
11
+ browser_eval: 'browser_evaluate',
12
+ };
13
+ function isLocalOrAllowed(url, ctx) {
14
+ try {
15
+ const u = new URL(url);
16
+ if (['localhost', '127.0.0.1', '0.0.0.0', '::1'].includes(u.hostname))
17
+ return true;
18
+ // Allow file:// for local fixtures.
19
+ if (u.protocol === 'file:')
20
+ return true;
21
+ return false;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ async function routeToPlaywright(ctx, toolName, args) {
28
+ if (toolName === 'browser_open' && typeof args.url === 'string') {
29
+ if (!isLocalOrAllowed(args.url, ctx) && ctx.container.policy.getMode() !== 'danger') {
30
+ return { ok: false, error: `External URL blocked by policy: ${args.url}. Only localhost is allowed by default.` };
31
+ }
32
+ }
33
+ if (!ctx.container.adapters.isEnabled('playwright')) {
34
+ return { ok: false, error: 'Playwright adapter is disabled. Enable adapters.playwright in config.' };
35
+ }
36
+ try {
37
+ const client = await ctx.container.adapters.ensure('playwright');
38
+ const pwTool = PW_MAP[toolName] ?? toolName;
39
+ const result = await client.callTool(pwTool, args);
40
+ return { ok: true, data: result };
41
+ }
42
+ catch (err) {
43
+ return { ok: false, error: `Playwright call failed: ${String(err)}` };
44
+ }
45
+ }
46
+ function bTool(name, description, mutates, props) {
47
+ return defineTool({
48
+ name,
49
+ description,
50
+ group: 'browser',
51
+ mutates,
52
+ inputSchema: { type: 'object', properties: props },
53
+ handler: (args, ctx) => routeToPlaywright(ctx, name, args),
54
+ });
55
+ }
56
+ export function browserTools() {
57
+ return [
58
+ bTool('browser_open', 'Navigate the browser to a URL (localhost only by default).', true, {
59
+ url: { type: 'string' },
60
+ }),
61
+ bTool('browser_snapshot', 'Return the accessibility tree snapshot of the page.', false, {}),
62
+ bTool('browser_click', 'Click an element on the page.', true, { element: { type: 'string' }, ref: { type: 'string' } }),
63
+ bTool('browser_type', 'Type text into an input.', true, {
64
+ element: { type: 'string' },
65
+ ref: { type: 'string' },
66
+ text: { type: 'string' },
67
+ }),
68
+ bTool('browser_console', 'Read browser console messages.', false, {}),
69
+ bTool('browser_network', 'List network requests made by the page.', false, {}),
70
+ bTool('browser_screenshot', 'Capture a screenshot of the page.', true, {}),
71
+ bTool('browser_close', 'Close the browser session.', true, {}),
72
+ bTool('browser_eval', 'Evaluate a JavaScript expression in the page context. HIGH risk.', true, {
73
+ function: { type: 'string', description: 'A JavaScript function body to evaluate in the page.' },
74
+ }),
75
+ ];
76
+ }