@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.
- package/README.md +181 -0
- package/dist/adapters/child-mcp/client.js +114 -0
- package/dist/adapters/child-mcp/registry.js +66 -0
- package/dist/audit/audit-log.js +45 -0
- package/dist/audit/event-types.js +1 -0
- package/dist/core/config.js +211 -0
- package/dist/core/container.js +51 -0
- package/dist/core/errors.js +37 -0
- package/dist/core/logger.js +8 -0
- package/dist/core/types.js +4 -0
- package/dist/dashboard/server.js +191 -0
- package/dist/lsp/protocol.js +116 -0
- package/dist/main.js +190 -0
- package/dist/managers/db-manager.js +161 -0
- package/dist/managers/lsp-manager.js +269 -0
- package/dist/managers/process-manager.js +140 -0
- package/dist/policy/approvals.js +143 -0
- package/dist/policy/command-policy.js +99 -0
- package/dist/policy/glob-match.js +61 -0
- package/dist/policy/path-policy.js +73 -0
- package/dist/policy/policy-engine.js +156 -0
- package/dist/policy/rate-limiter.js +96 -0
- package/dist/policy/risk.js +112 -0
- package/dist/policy/secret-policy.js +132 -0
- package/dist/server/mcp-server.js +144 -0
- package/dist/server/transports/http.js +133 -0
- package/dist/server/transports/stdio.js +14 -0
- package/dist/tools/adapter-tools.js +62 -0
- package/dist/tools/browser-tools.js +76 -0
- package/dist/tools/build-tools.js +78 -0
- package/dist/tools/code-tools.js +250 -0
- package/dist/tools/coverage-tools.js +135 -0
- package/dist/tools/db-tools.js +130 -0
- package/dist/tools/diff-util.js +45 -0
- package/dist/tools/error-parser.js +57 -0
- package/dist/tools/file-tools.js +319 -0
- package/dist/tools/format-tools.js +118 -0
- package/dist/tools/git-tools.js +371 -0
- package/dist/tools/index.js +63 -0
- package/dist/tools/memory-tools.js +54 -0
- package/dist/tools/output-schemas.js +100 -0
- package/dist/tools/pagination.js +92 -0
- package/dist/tools/pkg-tools.js +260 -0
- package/dist/tools/process-tools.js +128 -0
- package/dist/tools/registry.js +194 -0
- package/dist/tools/schema-lock.js +152 -0
- package/dist/tools/search-tools.js +176 -0
- package/dist/tools/security-tools.js +147 -0
- package/dist/tools/terminal-tools.js +57 -0
- package/dist/tools/workspace-tools.js +186 -0
- package/dist/workspace/memory-store.js +67 -0
- package/dist/workspace/onboarding.js +46 -0
- package/dist/workspace/project-detector.js +95 -0
- package/dist/workspace/workspace-manager.js +106 -0
- package/docs/adapters.md +76 -0
- package/docs/architecture.md +66 -0
- package/docs/roadmap.md +172 -0
- package/docs/security.md +94 -0
- package/docs/tools.md +129 -0
- package/examples/claude-desktop.json +18 -0
- package/examples/codex.toml +18 -0
- package/examples/config.basic.yaml +37 -0
- package/examples/config.full.yaml +120 -0
- 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
|
+
}
|