obsidian-mcp-server
Version:
MCP server for Obsidian vaults — read, write, search, and surgically edit notes, tags, and frontmatter via the Local REST API plugin. STDIO or Streamable HTTP.
140 lines • 6.33 kB
JavaScript
/**
* @fileoverview Server-specific config for obsidian-mcp-server.
* Loads OBSIDIAN_* env vars used by the Obsidian Local REST API service layer.
* @module config/server-config
*/
import { z } from '@cyanheads/mcp-ts-core';
import { parseEnvConfig } from '@cyanheads/mcp-ts-core/config';
const envBoolean = z.preprocess((val) => {
if (typeof val === 'boolean')
return val;
if (typeof val === 'string') {
const str = val.toLowerCase().trim();
return str === 'true' || str === '1';
}
return val;
}, z.boolean());
/**
* Comma-separated path-list preprocessor. Semantics (see issue #40):
* - undefined / `''` / whitespace-only → undefined (treat as unset; default = full vault)
* - `,` / `,,,` (separators only, no path content) → throw ZodError → ConfigurationError
* - mixed empties (`a,,b`) → drop empties → `['a', 'b']`
* - absolute path / `..` traversal → throw
* - valid: `\` → `/` + lower-case + trim trailing slash + dedupe (preserves first occurrence order)
*
* Separator normalization is required for parity with `PathPolicy.normalize()`,
* which also collapses `\` → `/` on candidate paths. Without matching
* normalization here, a prefix like `Foo\Bar` would never match a candidate
* `Foo\Bar\note.md` (candidate becomes `foo/bar/note.md`, prefix stays as
* `foo\bar`).
*/
const envPathList = z
.preprocess((val) => {
if (val === undefined || val === null)
return;
if (Array.isArray(val))
return val;
if (typeof val !== 'string')
return val;
if (val.trim() === '')
return;
const parts = val
.split(',')
.map((s) => s.trim())
.filter((s) => s.length > 0);
return parts;
}, z
.array(z.string())
.optional()
.transform((parts, ctx) => {
if (parts === undefined)
return;
if (parts.length === 0) {
ctx.addIssue({
code: 'custom',
message: 'contained separators but no valid paths after trimming',
});
return z.NEVER;
}
const seen = new Set();
const out = [];
for (const raw of parts) {
if (raw.startsWith('/') || raw.startsWith('\\')) {
ctx.addIssue({
code: 'custom',
message: `must be vault-relative; got absolute path '${raw}'`,
});
return z.NEVER;
}
const segments = raw.split(/[\\/]/);
if (segments.includes('..')) {
ctx.addIssue({
code: 'custom',
message: `must not contain '..' traversal; got '${raw}'`,
});
return z.NEVER;
}
const normalized = raw.replace(/\\/g, '/').toLowerCase().replace(/\/+$/, '');
if (normalized.length === 0)
continue;
if (seen.has(normalized))
continue;
seen.add(normalized);
out.push(normalized);
}
return out.length > 0 ? out : undefined;
}))
.describe('Comma-separated list of vault-relative folder prefixes. Empty/whitespace falls back to unset.');
const ServerConfigSchema = z.object({
apiKey: z
.string()
.min(1)
.describe('Bearer token for the Obsidian Local REST API plugin (Settings → Community Plugins → Local REST API).'),
baseUrl: z
.string()
.url()
.default('http://127.0.0.1:27123')
.describe('Base URL of the Obsidian Local REST API. Defaults to http://127.0.0.1:27123 — enable "Non-encrypted (HTTP) Server" in the plugin settings to match. Use https://127.0.0.1:27124 to hit the always-on HTTPS port (self-signed cert; pair with OBSIDIAN_VERIFY_SSL=false).'),
verifySsl: envBoolean
.default(false)
.describe("Whether to verify the TLS certificate on the Obsidian endpoint. Defaults to false because the plugin uses a self-signed cert. On Node, the dispatcher's `rejectUnauthorized` option handles this without any process-wide change. On Bun, the runtime ignores that option, so the service additionally sets `NODE_TLS_REJECT_UNAUTHORIZED=0` — that fallback is scoped to Bun only."),
requestTimeoutMs: z.coerce
.number()
.int()
.positive()
.default(30_000)
.describe('Per-request timeout in milliseconds.'),
enableCommands: envBoolean
.default(false)
.describe('Opt-in flag for the command-palette pair (`obsidian_list_commands` + `obsidian_execute_command`). Off by default — Obsidian commands are opaque and can be destructive.'),
readPaths: envPathList.describe('Optional vault-relative folder allowlist for read operations. Comma-separated; prefix-based with implicit recursion; case-insensitive; trailing slashes normalized. Unset = full vault.'),
writePaths: envPathList.describe('Optional vault-relative folder allowlist for write operations. Same syntax as OBSIDIAN_READ_PATHS. Write paths are implicitly readable. Unset = full vault.'),
readOnly: envBoolean
.default(false)
.describe('Global kill switch. When true, denies every write regardless of OBSIDIAN_WRITE_PATHS, and suppresses the OBSIDIAN_ENABLE_COMMANDS pair (commands can mutate). Defaults to false.'),
omnisearchUrl: z
.string()
.url()
.optional()
.describe('Override URL for the Omnisearch plugin HTTP server. When unset, derives from OBSIDIAN_BASE_URL host with port 51361 (falling back to http://localhost:51361). Used to enable the optional `omnisearch` mode on `obsidian_search_notes`; if the URL is unreachable at startup, the mode is omitted from the tool schema.'),
});
let _config;
export function getServerConfig() {
_config ??= parseEnvConfig(ServerConfigSchema, {
apiKey: 'OBSIDIAN_API_KEY',
baseUrl: 'OBSIDIAN_BASE_URL',
verifySsl: 'OBSIDIAN_VERIFY_SSL',
requestTimeoutMs: 'OBSIDIAN_REQUEST_TIMEOUT_MS',
enableCommands: 'OBSIDIAN_ENABLE_COMMANDS',
readPaths: 'OBSIDIAN_READ_PATHS',
writePaths: 'OBSIDIAN_WRITE_PATHS',
readOnly: 'OBSIDIAN_READ_ONLY',
omnisearchUrl: 'OBSIDIAN_OMNISEARCH_URL',
});
return _config;
}
/** Test hook to reset the cached config. Not used at runtime. */
export function resetServerConfig() {
_config = undefined;
}
//# sourceMappingURL=server-config.js.map