actual-mcp-server 0.25.0 → 0.25.2

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 CHANGED
@@ -169,4 +169,4 @@ MIT, see [LICENSE](LICENSE). Built on [Actual Budget](https://actualbudget.org/)
169
169
 
170
170
  ---
171
171
 
172
- **Version:** 0.25.0 | **Tool Count:** 84 (verified LibreChat-compatible)
172
+ **Version:** 0.25.2 | **Tool Count:** 84 (verified LibreChat-compatible)
@@ -87,35 +87,54 @@ export function findWeakTokenVars(env, { oidcEnabled }) {
87
87
  }
88
88
  /** #612: the hostnames every open-mode server accepts, in the SDK's bracketed form. */
89
89
  const LOOPBACK_HOSTNAMES = ['localhost', '127.0.0.1', '[::1]'];
90
+ /**
91
+ * The hostname the SDK compares for `host` (a bind address or advertised name):
92
+ * lowercased, IPv6 bracketed and canonical. `undefined` for an empty or wildcard
93
+ * value, which names no single host.
94
+ */
95
+ function canonicalHostname(host) {
96
+ const h = host?.trim().toLowerCase();
97
+ if (!h || h === '0.0.0.0' || h === '::' || h === '[::]')
98
+ return undefined;
99
+ const bracketed = h.includes(':') && !h.startsWith('[') ? `[${h}]` : h;
100
+ // The SDK compares the URL-parsed Host, which is the canonical form
101
+ // ([2001:db8:0:0::5] arrives as [2001:db8::5]), so store the same form.
102
+ try {
103
+ return new URL(`http://${bracketed}`).hostname;
104
+ }
105
+ catch {
106
+ return bracketed;
107
+ }
108
+ }
90
109
  /**
91
110
  * #612: the Host (and, in open mode, Origin) hostnames the HTTP server accepts, or
92
111
  * `null` to skip the check. DNS rebinding protection: a hostile page can rebind its
93
112
  * own name to 127.0.0.1, but cannot make the browser send a Host it does not own.
94
113
  *
95
- * - Open mode (no bearer token, no OIDC): the loopback names, `allowedHosts`, and the
114
+ * - Open mode (no bearer token, no OIDC): the loopback names, `allowedHosts`, the
96
115
  * bind address itself when it is a concrete, non-wildcard address (an IP-literal
97
- * Host cannot come out of rebinding). An IPv6 bind is added bracketed and canonical.
116
+ * Host cannot come out of rebinding), and (#615) the advertised host, the address
117
+ * the server prints as its own URL (MCP_BRIDGE_PUBLIC_HOST or the detected LAN
118
+ * IP): an operator-controlled name or an IP literal, never one a hostile page can
119
+ * make the browser send. Not on a loopback bind, which the LAN cannot reach.
120
+ * IPv6 values are added bracketed and canonical.
98
121
  * - Authenticated: `null` when `allowedHosts` is empty (unchanged behaviour), else
99
- * `allowedHosts` plus the loopback names.
122
+ * `allowedHosts` plus the loopback names. The bind and advertised hosts are not
123
+ * added: an operator list stays exact.
100
124
  *
101
125
  * Pure: `allowedHosts` is the already-validated MCP_ALLOWED_HOSTS list.
102
126
  */
103
- export function resolveAllowedHosts({ openMode, allowedHosts, bindHost, }) {
127
+ export function resolveAllowedHosts({ openMode, allowedHosts, bindHost, advertisedHost, }) {
104
128
  if (!openMode && allowedHosts.length === 0)
105
129
  return null;
106
130
  const hosts = new Set([...LOOPBACK_HOSTNAMES, ...allowedHosts]);
107
- if (openMode && bindHost) {
108
- const h = bindHost.trim().toLowerCase();
109
- if (h && h !== '0.0.0.0' && h !== '::' && h !== '[::]') {
110
- const bracketed = h.includes(':') && !h.startsWith('[') ? `[${h}]` : h;
111
- // The SDK compares the URL-parsed Host, which is the canonical form
112
- // ([2001:db8:0:0::5] arrives as [2001:db8::5]), so store the same form.
113
- let canonical = bracketed;
114
- try {
115
- canonical = new URL(`http://${bracketed}`).hostname;
116
- }
117
- catch { /* keep as typed */ }
118
- hosts.add(canonical);
131
+ if (openMode) {
132
+ // A loopback bind is unreachable from the LAN, so the advertised (LAN) host is
133
+ // left out there: adding it would only widen which Origins may call localhost.
134
+ const advertised = isLoopbackHost(bindHost) ? undefined : advertisedHost;
135
+ for (const h of [canonicalHostname(bindHost), canonicalHostname(advertised)]) {
136
+ if (h)
137
+ hosts.add(h);
119
138
  }
120
139
  }
121
140
  return [...hosts];
@@ -94,7 +94,21 @@ advertisedUrl) {
94
94
  // #612: DNS rebinding protection, mounted first so it covers every route (/health,
95
95
  // /metrics, /.well-known/* and OPTIONS included). It reads req.headers.host only,
96
96
  // never X-Forwarded-Host or req.hostname, so a proxy header cannot bypass it.
97
- const allowedHosts = resolveAllowedHosts({ openMode, allowedHosts: config.MCP_ALLOWED_HOSTS, bindHost });
97
+ // #615: the host this server prints as its own URL is accepted too (open mode only).
98
+ let advertisedHost;
99
+ try {
100
+ advertisedHost = advertisedUrl ? new URL(advertisedUrl).hostname : undefined;
101
+ }
102
+ catch {
103
+ logger.warn(`[HTTP] Advertised URL ${advertisedUrl} does not parse; its host is not accepted. Check MCP_BRIDGE_PUBLIC_HOST (a bare host or IP, no scheme or port).`);
104
+ }
105
+ // MCP_BRIDGE_PUBLIC_HOST=https://nas.lan parses to the hostname "https": say so rather than
106
+ // accept it silently. A scheme or path always carries a "/"; a port already fails the parse.
107
+ const publicHost = process.env.MCP_BRIDGE_PUBLIC_HOST?.trim().toLowerCase();
108
+ if (advertisedHost && publicHost?.includes('/')) {
109
+ logger.warn(`[HTTP] MCP_BRIDGE_PUBLIC_HOST (${publicHost}) did not parse to a host name (got ${advertisedHost}). Set a bare host or IP, no scheme or port.`);
110
+ }
111
+ const allowedHosts = resolveAllowedHosts({ openMode, allowedHosts: config.MCP_ALLOWED_HOSTS, bindHost, advertisedHost });
98
112
  if (allowedHosts) {
99
113
  const rejectHost = (req, reason, value) => logger.warn('[HTTP] Rejected request: hostname not allowed (DNS rebinding protection, #612)', {
100
114
  header: reason, hostname: value.slice(0, 253), path: req.path,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "actual-mcp-server",
3
3
  "displayName": "Actual MCP Server",
4
- "version": "0.25.0",
4
+ "version": "0.25.2",
5
5
  "mcpName": "io.github.agigante80/actual-mcp-server",
6
6
  "engines": {
7
7
  "node": ">=22.0.0",
@@ -71,7 +71,7 @@
71
71
  "audit:write-effect": "node scripts/check-write-effect-audit.mjs"
72
72
  },
73
73
  "dependencies": {
74
- "@actual-app/api": "^26.10.0",
74
+ "@actual-app/api": "^26.11.0",
75
75
  "@modelcontextprotocol/sdk": "^1.32.2",
76
76
  "debug": "^4.4.3",
77
77
  "dotenv": "^18.0.9",