The portal review forbids disabling no-deprecated / prefer-window-timers / no-global-this (an inline eslint-disable for these is a gating Error) and errors on undescribed directive comments. The reflex to suppress fails the review — it cost a round trip this session (the setWarning 1037 suppression). Records the rule, the fail-fast behavior, and the four fix patterns that worked across 0.11.32→0.11.33 (display→render, setWarning→setClass, global timers→window.* + test polyfill, Server→McpServer.server), so the next session reaches for the fix instead of the disable.
23 KiB
Claude Development Guidelines for Obsidian MCP Plugin
Project Context
This is a hybrid Obsidian plugin that combines:
- Local REST API functionality (from coddingtonbear's plugin)
- Semantic MCP operations (from aaronsb/obsidian-semantic-mcp)
- Direct Obsidian API integration for enhanced performance
The critical architectural pattern is preserving the ObsidianAPI abstraction layer while replacing HTTP calls with direct Obsidian plugin API calls. This allows reuse of all existing MCP server logic while gaining performance benefits.
Code Quality Guidelines
SOLID Principles Application
-
Single Responsibility:
ObsidianAPIclass handles only vault operations abstractionMCPServerclass handles only MCP protocol operationsHTTPServerclass handles only REST endpoint management- Plugin main class handles only Obsidian plugin lifecycle
-
Open/Closed:
- ObsidianAPI interface remains stable for extensions
- MCP operations extensible through semantic router pattern
- HTTP endpoints extensible without modifying core logic
-
Liskov Substitution:
- New ObsidianAPI implementation must be drop-in replacement
- All method signatures and return types must match exactly
- Error handling behavior must be preserved
-
Interface Segregation:
- Separate interfaces for vault operations, search operations, and workspace operations
- MCP protocol separated from HTTP REST protocol
- Plugin settings separated from server configuration
-
Dependency Inversion:
- Depend on Obsidian Plugin API abstractions, not concrete implementations
- MCP server depends on ObsidianAPI interface, not specific implementation
- HTTP server depends on operation interfaces, not direct vault access
Architecture Patterns
Critical Abstraction Layer
// This interface MUST remain stable
interface IObsidianAPI {
getFile(path: string): Promise<ObsidianFileResponse>;
listFiles(directory?: string): Promise<string[]>;
searchSimple(query: string): Promise<any[]>;
// ... all existing methods preserved
}
// Implementation changes from HTTP to direct API
class ObsidianAPI implements IObsidianAPI {
constructor(private app: App) {} // Direct plugin access
async getFile(path: string): Promise<ObsidianFileResponse> {
// Direct vault access instead of HTTP call
const file = this.app.vault.getAbstractFileByPath(path);
// ... implementation
}
}
Performance-Critical Patterns
- Caching Layer: Implement intelligent caching for frequently accessed files
- Lazy Loading: Load heavy operations only when needed
- Batch Operations: Combine multiple vault operations where possible
- Memory Management: Proper cleanup of file handles and event listeners
Error Handling Patterns
// Preserve exact error types and messages from HTTP implementation
class VaultError extends Error {
constructor(message: string, public code: string, public status: number) {
super(message);
}
}
// Maintain compatibility with existing error handling
async getFile(path: string): Promise<ObsidianFileResponse> {
try {
const file = this.app.vault.getAbstractFileByPath(path);
if (!file) {
throw new VaultError(`File not found: ${path}`, 'ENOENT', 404);
}
// ... rest of implementation
} catch (error) {
// Transform plugin errors to match HTTP API errors
throw this.transformError(error);
}
}
Development Workflow
BRAT Development Release Process
When developing with Obsidian BRAT (Beta Reviewer's Auto-update Tool) for plugin side-loading:
Development vs Release Workflow
Development (no releases created):
- Push commits to main freely - no automatic releases triggered
- Test locally with
npm run build - Iterate as needed without polluting release history
Creating a Release (manual trigger):
Via GitHub UI:
- Go to Actions → Create Release
- Click Run workflow
- Optionally add release notes
- Click Run
Via CLI:
# Simple release
gh workflow run release.yml
# With release notes
gh workflow run release.yml -f release_notes="Fixed VS Code compatibility, improved search"
Version Updates
- ONLY update
package.jsonversion -sync-version.mjsautomatically syncs tomanifest.jsonandversion.ts - DO NOT manually update
manifest.json- the automation handles this - Bump version in
package.jsonbefore triggering a release
Tag Management
- Release workflow creates tags automatically (no 'v' prefix)
- If a tag already exists for that version, the release is skipped
- To re-release same version, delete existing tag first:
git tag -d X.Y.Z && git push origin :refs/tags/X.Y.Z
BRAT User Experience
- Users install via:
aaronsb/obsidian-mcp-pluginin BRAT - BRAT checks GitHub releases for updates automatically
- Version detection relies on
manifest.jsonversion field - Release assets (main.js, manifest.json, styles.css) are auto-generated by workflow
Version Naming Convention
- Major releases:
X.Y.Z(e.g., 0.4.4) - NO 'v' prefix - Patch releases:
X.Y.Za,X.Y.Zb(e.g., 0.4.4a, 0.4.4b) - NO 'v' prefix - Pre-releases: All releases ship as prereleases by default (
make release-patchetc.) - Promoting to stable: When a release is bounded and proven, run
make promote(ormake promote TAG=X.Y.Z) to flip it from prerelease → stable and mark it as GitHub's "Latest" release. This is also what makes the/releases/latest/download/<asset>URL resolve — the MCPB download link in plugin Settings relies on it. - IMPORTANT: Obsidian requires release tags WITHOUT 'v' prefix
- Exploratory releases: Use letter suffix (a, b, c) for testing new features
File Organization
src/
├── main.ts # Plugin entry point
├── obsidian-api.ts # Direct API implementation (CRITICAL)
├── mcp-server.ts # MCP protocol handling
├── http-server.ts # REST API endpoints
├── semantic/ # Reused from obsidian-semantic-mcp
│ ├── router.ts # Semantic operations router
│ └── operations/ # Individual operation implementations
├── types/ # TypeScript type definitions
└── utils/ # Shared utilities
Testing Strategy
- Unit Tests: Each ObsidianAPI method tested against expected interface
- Integration Tests: Full MCP and REST workflows tested
- Performance Tests: Benchmarking against HTTP-based implementation
- Compatibility Tests: Existing client code works without changes
Build Pipeline
{
"scripts": {
"dev": "tsc --watch",
"build": "tsc && node build-plugin.js",
"test": "jest",
"test:performance": "node performance-tests.js",
"package": "npm run build && npm run test && node package-release.js"
}
}
Pre-Push Quality Checks
ALWAYS run these commands before pushing to GitHub:
# 1. Build the project to catch TypeScript errors
npm run build
# 2. Run linting to ensure code quality
npm run lint
# 3. Run tests to catch regressions
npm test
# 4. If all pass, commit and push
git add -A && git commit -m "..."
git push origin main
Quick one-liner for all checks:
npm run build && npm run lint && npm test && echo "✅ All checks passed!"
Note: Even for "simple" changes like updating descriptions or documentation, running these checks ensures no accidental syntax errors or regressions are introduced.
Supply Chain — 7-Day Hold on Routine Upgrades (security fixes exempt)
npm supply-chain attacks have been ticking up — malicious package versions get published, ingested by automation, then discovered/yanked days later. To buy time for community discovery:
Land routine upgrades only to versions published more than 7 days ago — but security fixes are exempt and land immediately.
The exemption exists because the hold otherwise fights our own freshness goal: it delays exactly the vuln patches that Obsidian's plugin review (and npm audit) flag, leaving us knowingly exposed to a named, real CVE to guard against a hypothetical malicious republish. For a security fix the known exposure wins.
Practical application:
- Security fix → land it now. If a dependabot/manual upgrade resolves a flagged vulnerability (dependabot security PR,
npm auditadvisory, or an Obsidian-review dependency warning), merge as soon as it's green regardless of version age. - Routine bump → 7-day hold. For non-security version bumps, check the PR's open date. If it's ≥7 days old, proceed; if younger, hold (don't close — it ages into eligibility on its own).
- The same split applies to manual
npm install/npm update: security fixes immediately, routine pins to versions older than 7 days.
Re-run npm audit after each merge batch to see what residual exposure remains.
Plugin-Specific Guidelines
Obsidian Plugin Lifecycle
export default class ObsidianMCPPlugin extends Plugin {
private obsidianAPI: ObsidianAPI;
private mcpServer: MCPServer;
private httpServer: HTTPServer;
async onload() {
// 1. Initialize API abstraction layer FIRST
this.obsidianAPI = new ObsidianAPI(this.app);
// 2. Initialize servers with API dependency
this.mcpServer = new MCPServer(this.obsidianAPI);
this.httpServer = new HTTPServer(this.obsidianAPI);
// 3. Start servers
await this.startServers();
// 4. Register UI components
this.addSettingTab(new MCPSettingTab(this.app, this));
}
async onunload() {
// Clean shutdown in reverse order
await this.stopServers();
this.obsidianAPI.cleanup();
}
}
Performance Monitoring
// Add performance tracking for optimization
class PerformanceTracker {
static async measure<T>(name: string, operation: () => Promise<T>): Promise<T> {
const start = performance.now();
const result = await operation();
const duration = performance.now() - start;
console.log(`${name}: ${duration.toFixed(2)}ms`);
return result;
}
}
// Usage in ObsidianAPI methods
async getFile(path: string): Promise<ObsidianFileResponse> {
return PerformanceTracker.measure(`getFile:${path}`, async () => {
// ... implementation
});
}
Settings Management
interface MCPPluginSettings {
httpEnabled: boolean;
httpPort: number;
httpsPort: number;
enableSSL: boolean;
debugLogging: boolean;
performanceMetrics: boolean;
}
const DEFAULT_SETTINGS: MCPPluginSettings = {
httpEnabled: true,
httpPort: 27123,
httpsPort: 27124,
enableSSL: true,
debugLogging: false,
performanceMetrics: false
};
Migration Guidelines
From HTTP-based Setup
- Configuration Migration: Automatically detect and import REST API plugin settings
- Port Compatibility: Default to same ports as REST API plugin
- Feature Parity: All existing functionality must work identically
- Performance Communication: Clearly communicate performance improvements
Backward Compatibility Requirements
- API Responses: Identical JSON structure and field names
- Error Codes: Same HTTP status codes and error messages
- Authentication: Support existing API key mechanisms
- Headers: Preserve expected request/response headers
Documentation Standards
Code Documentation
/**
* Enhanced file retrieval with direct vault access
*
* @param path - File path relative to vault root
* @returns Promise resolving to file content and metadata
* @throws VaultError when file not found or access denied
*
* Performance: ~1-5ms (vs ~50-100ms HTTP implementation)
* Compatibility: 100% compatible with HTTP API response format
*/
async getFile(path: string): Promise<ObsidianFileResponse> {
// Implementation...
}
API Documentation
- Maintain compatibility documentation showing HTTP vs Direct API equivalence
- Performance benchmarks for each operation
- Migration examples for common use cases
- Troubleshooting guide for plugin-specific issues
Success Metrics
Performance Targets
- File Operations: <10ms (vs ~50-100ms HTTP)
- Search Operations: <50ms (vs ~100-300ms HTTP)
- Directory Listing: <5ms (vs ~30-60ms HTTP)
- Memory Usage: Stable, no leaks during extended operation
Quality Targets
- API Compatibility: 100% backward compatible
- Test Coverage: >90% code coverage
- Error Handling: Graceful degradation for all failure modes
- Documentation: Complete user and developer documentation
Community Targets
- BRAT Testing: 100+ beta installations
- Feedback Integration: Active response to community feedback
- Migration Success: Smooth transition for existing users
- Plugin Directory: Successful submission and approval
Obsidian Community Plugin Distribution
Status: The plugin is live in the community directory as of 2026-05-16 (in submittal since 2025-07-04). It is a maintained, shipped plugin now — not a submission in progress.
Historical note: Obsidian previously required a pull request against the
obsidianmd/obsidian-releasesrepo (editingcommunity-plugins.json) plus a manual "keep the PR alive" refresh dance. That process is defunct. Obsidian moved submission and maintenance to the community developer portal (community.obsidian.md). Do not recreate theobsidian-releasesfork workflow — there is no PR to maintain anymore.
Architecture: portal is the source of truth, community-plugins.json is a mirror
The developer portal (community.obsidian.md) is now authoritative. A bot
mirrors approved plugins from the portal into
obsidianmd/obsidian-releases/community-plugins.json (commits titled
chore: Mirror community plugins and themes). The desktop app's in-app
community browser still reads that mirror, not the portal directly.
Consequence: after a plugin is approved/updated on the portal, there is a propagation lag before it appears in the in-app browser search — the mirror bot has to run, then the app refreshes its cached list (a restart helps only after the mirror includes you). This is normal, not a bug. The portal page and its "Add to Obsidian" deep link work immediately. If still absent from the mirror after ~24h / many mirror commits, that's a real pipeline miss worth raising with Obsidian — not a repo-side fix.
make promote now auto-updates real users, not just BRAT testers. The
prerelease → BRAT → promote loop is the safety rail, not ceremony.
How distribution works now
The portal scans this repo's GitHub Releases. For the plugin to be scanned and distributed:
- There must be a stable (non-prerelease) "Latest" release whose tag
exactly matches the
versionfield inmanifest.json. - Release tags use no
vprefix —0.11.21, notv0.11.21. The release workflow already produces the correct format. versions.jsonmust contain a"<version>": "<minAppVersion>"entry matchingmanifest.json(make release-*maintains this).- Releases ship as prereleases by default, and a prerelease is invisible
to the portal. Run
make promoteto flip the proven release to stable + "Latest". This is the step that makes the directory (and the MCPBreleases/latest/download/...link) pick up the new version.
Validating before publishing
The developer portal offers a "preview a branch scan" that accepts a
branch, tag, or commit SHA — use it to confirm a release candidate passes
Obsidian's checks before running make promote. Treat it as a
validation/dry-run tool: end-user installs and updates still come from the
matching stable release tag's assets, not from a scanned branch.
Where the listing text comes from
- Short description — repo-driven. Single source of truth is
package.json;sync-version.mjspropagates it tomanifest.json; usemake set-description DESC='...'(never hand-edit the JSON). The portal's one-liner refreshes on the next scan after release. - Long "About" — not in the repo. Obsidian seeded this portal-side
during the community.obsidian.md migration; it is decoupled from
package.json/manifest.jsonand only editable in the authenticated developer portal. Repo edits will not change it — update it there.
Reading the scorecard as free signal — make scorecard
The public plugin page (community.obsidian.md/plugins/semantic-vault-mcp)
renders an automated Health grade and Review scan — deterministic
static analysis, not an LLM. It is free signal we can pull without logging in:
make scorecard # prose + freshness delta, for reading
node scripts/scorecard.mjs --json # one JSON line, for diffing across releases
Key facts about this tool:
- Freshness is not guaranteed. A fresh scan is only triggered from the
authenticated developer portal. The public page reflects the last release
Obsidian scanned, so the script reports a
freshnessdelta (portal version vsmanifest.json).STALEmeans a logged-in re-scan is still needed. - Drift guard. The findings come from Obsidian's Next.js RSC payload, so
the parser is coupled to their internal serialization. If the page
structure changes, the script exits non-zero with a
SCRAPER DRIFTbanner — that means reviewscripts/scorecard.mjs, not that the scorecard is clean. The scorecard body itself never gates anything.
Known accepted review findings
The full submit review passed (0.11.23). Not every finding is a defect — several are deliberate trade-offs or false positives. Do not "fix" these by reverting the decision behind them; they were analysed and accepted:
.mcpbadditional files (Releases, Recommendation) — expected. Per ADR-102, releases intentionally ship the.mcpbbundles soreleases/latest/download/obsidian-mcp.mcpb(the plugin Settings download) resolves. Clearing it would break ADR-102. Confirmed non-gating.- Direct filesystem access (Behavior, Warning) —
fsis used inpath-validator.ts(boundary enforcement viarealpathSync— itself a security control) andcertificate-manager.ts(self-signed TLS certs). Load-bearing; cannot remove without dropping HTTPS. - Vault enumeration (Behavior, Recommendation) —
vault.getFilesetc. is the plugin's core purpose (semantic search / graph). By design. - Clipboard access (Behavior, Recommendation) — write-only, user-initiated (copy API key / copy buttons). Benign.
- README "unfilled placeholder text" (Warning) — false positive.
No real placeholders exist; the heuristic trips on legitimate markdown
badge/link labels (
[BRAT],[MIT], …). Mangling valid markdown to appease it is the wrong action — leave it. - "… scan not available" disclosures — neutral. Obsidian's malware/dependency/obfuscation scanners did not run; not a failure.
Dynamic code execution — the Bases-evaluator new Function (the
exploitable vector: arbitrary JS from a synced/shared .base) is removed
per ADR-201, implemented in #180 (PR #185 corpus/baseline, PR #186
expression-eval swap). The evaluator now parses with expression-eval (jsep
grammar, no globals) plus a tested constructor/__proto__/prototype/
this denylist; a differential corpus proves behavioural parity.
Caveat for future scans: grep "new Function" main.js is not zero by
design. The residual occurrences are transitive-dependency codegen — ajv
@6.14.0 schema-validator compilation + a library deprecation shim — a
different, library-internal class not reachable from vault content. They
were already in 0.11.25's bundle, the release that passed full review with
only the fs Warning, so they were non-gating then. The "Dynamic Code
Execution" Recommendation was attributed to the Bases path specifically;
this clears that path. Whether a heuristic re-scan re-flags the ajv-class
residual is unknown until the next scan (scorecard blind — #183); if it
does, it is the transitive class above, not the closed Bases vector.
Actionable findings became issues/PRs: #163/#164/#170 (SSL + attestation, shipped), #171/#173 (build-dep + CSS, shipped), #174 (js-yaml, shipped), #180 (sandboxed Bases evaluator, PR #185/#186), #176 (this doc). Scorecard CI-gate idea: #165.
Passing the source-code review — fix, never suppress
The portal's Source code check forbids disabling a specific set of lint
rules. An inline eslint-disable for @typescript-eslint/no-deprecated,
obsidianmd/prefer-window-timers, or obsidianmd/no-global-this is
itself a gating Error ("Disabling 'X' is not allowed"). It also errors on
any directive comment with no inline description ("Unexpected undescribed
directive comment"). So the reflex "just eslint-disable it" fails the
review — louder than the warning it was hiding. Fix the code or use a
non-deprecated path; never suppress these.
Nuance: our local eslint config disabling a rule for a whole file surfaces as a non-gating Warning in the review (tolerated). It's the inline directive comment that's banned. And the review is fail-fast — it halts at the first Error, so clearing one can reveal the next.
Patterns that worked (the 0.11.32 → 0.11.33 cleanup):
PluginSettingTab.display()deprecated (1.13.0) → keepdisplay()as the supported cross-version fallback, move its body to a non-deprecatedrender(), and callrender()from internal re-renders (#227).ButtonComponent.setWarning()deprecated;setDestructive()needs 1.13.0 > ourminAppVersion1.6.6 → apply the style class directly:setClass('mod-warning')(#229).- Global
setTimeout/setIntervalandglobalThis→window.*/window.console, and aliaswindow→globalThisintests/setup.tsso the node test env resolves them (#227). - Low-level
Serverdeprecated;McpServer.registerToolwants Zod → constructMcpServer(non-deprecated) and register our raw JSON-Schema handlers on its.serveraccessor (a non-deprecated property); theServersymbol then never appears in source (#230). VerifiedMcpServerregisters its own tool handlers lazily (only viaregisterTool, which we never call), so it can't shadow ours — no empty-tools/listregression (cf. #221).
Allowed: a described disable of a non-forbidden rule (e.g.
no-require-imports, no-control-regex with -- reason) passes review — the
ban is that specific rule set, not all directives. To raise minAppVersion and
adopt the 1.13.0 APIs outright (getSettingDefinitions, setDestructive), see
#224.
Important Notes
- Critical Path: The ObsidianAPI abstraction layer is the cornerstone of this architecture
- Performance Focus: Every operation should demonstrate measurable improvement
- Compatibility First: When in doubt, maintain compatibility over new features
- Plugin Ecosystem: Consider integration opportunities with other Obsidian plugins
- Community Feedback: BRAT testing phase is crucial for identifying issues
This plugin represents the evolution of Obsidian AI integration. Maintain the highest standards as we build the definitive solution for AI-Obsidian connectivity.