Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 107 additions & 6 deletions src/core/mcp/McpTester.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Client } from '@modelcontextprotocol/sdk/client';
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp';
import * as http from 'http';
import * as https from 'https';

import { getEnhancedPath } from '../../utils/env';
import { parseCommand } from '../../utils/mcp';
Expand All @@ -27,6 +27,110 @@ interface UrlServerConfig {
headers?: Record<string, string>;
}

/**
* Custom MCP transport using Node.js native http/https modules.
* Bypasses browser CORS restrictions that block Obsidian's Electron renderer
* (Origin: app://obsidian.md) from connecting to MCP servers.
*/
class NodeHttpTransport {
private _url: URL;
private _headers: Record<string, string>;
private _sessionId?: string;

// Transport interface callbacks
onmessage?: (message: unknown) => void;
onerror?: (error: Error) => void;
onclose?: () => void;

constructor(url: URL, headers?: Record<string, string>) {
this._url = url;
this._headers = headers ?? {};
}

async start(): Promise<void> {
// Nothing to do on start — the Client will send initialize via send()
}

async send(message: unknown): Promise<void> {
const body = JSON.stringify(message);
const mod = this._url.protocol === 'https:' ? https : http;

const headers: Record<string, string> = {
...this._headers,
'Content-Type': 'application/json',
Accept: 'application/json, text/event-stream',
};
if (this._sessionId) {
headers['mcp-session-id'] = this._sessionId;
}

return new Promise<void>((resolve, reject) => {
const req = mod.request(
this._url,
{ method: 'POST', headers },
(res: http.IncomingMessage) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
const sessionHeader = res.headers['mcp-session-id'];
if (sessionHeader) {
this._sessionId = Array.isArray(sessionHeader) ? sessionHeader[0] : sessionHeader;
}

if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
reject(new Error(`HTTP ${res.statusCode}`));
return;
}

const text = Buffer.concat(chunks).toString('utf-8').trim();
if (!text) {
resolve();
return;
}

// Handle SSE-formatted responses (content-type: text/event-stream)
const contentType = res.headers['content-type'] ?? '';
if (contentType.includes('text/event-stream')) {
const lines = text.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6).trim();
if (data) {
try {
this.onmessage?.(JSON.parse(data));
} catch {
// Skip unparseable SSE data lines
}
}
}
}
resolve();
return;
}

// Handle JSON response
try {
this.onmessage?.(JSON.parse(text));
resolve();
} catch {
reject(new Error('Invalid JSON response'));
}
});
res.on('error', (err: Error) => reject(err));
},
);

req.on('error', (err: Error) => reject(err));
req.write(body);
req.end();
});
}

async close(): Promise<void> {
this.onclose?.();
}
}

export async function testMcpServer(server: ClaudianMcpServer): Promise<McpTestResult> {
const type = getMcpServerType(server.config);

Expand All @@ -47,10 +151,7 @@ export async function testMcpServer(server: ClaudianMcpServer): Promise<McpTestR
} else {
const config = server.config as UrlServerConfig;
const url = new URL(config.url);
const options = config.headers ? { requestInit: { headers: config.headers } } : undefined;
transport = type === 'sse'
? new SSEClientTransport(url, options)
: new StreamableHTTPClientTransport(url, options);
transport = new NodeHttpTransport(url, config.headers);
}
} catch (error) {
return {
Expand Down
104 changes: 77 additions & 27 deletions src/core/storage/McpStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
*
* MCP server configurations are stored in Claude Code-compatible format
* with optional Claudian-specific metadata in _claudian field.
* Also loads read-only servers from the user-level ~/.claude/settings.json
* (vault servers take precedence on name collision).
*
* File format:
* {
Expand All @@ -17,6 +19,10 @@
* }
*/

import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';

import type {
ClaudianMcpConfigFile,
ClaudianMcpServer,
Expand All @@ -29,49 +35,93 @@ import type { VaultFileAdapter } from './VaultFileAdapter';
/** Path to MCP config file relative to vault root. */
export const MCP_CONFIG_PATH = '.claude/mcp.json';

/** Absolute path to the user-level Claude Code settings file containing global MCP servers. */
export const GLOBAL_MCP_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');

export class McpStorage {
constructor(private adapter: VaultFileAdapter) {}
constructor(private adapter: VaultFileAdapter) { }

async load(): Promise<ClaudianMcpServer[]> {
let vaultServers: ClaudianMcpServer[] = [];

try {
if (!(await this.adapter.exists(MCP_CONFIG_PATH))) {
return [];
// Fall through to global loading below
} else {
const content = await this.adapter.read(MCP_CONFIG_PATH);
const file = JSON.parse(content) as ClaudianMcpConfigFile;

if (file.mcpServers && typeof file.mcpServers === 'object') {
const claudianMeta = file._claudian?.servers ?? {};

for (const [name, config] of Object.entries(file.mcpServers)) {
if (!isValidMcpServerConfig(config)) {
continue;
}

const meta = claudianMeta[name] ?? {};
const disabledTools = Array.isArray(meta.disabledTools)
? meta.disabledTools.filter((tool) => typeof tool === 'string')
: undefined;
const normalizedDisabledTools =
disabledTools && disabledTools.length > 0 ? disabledTools : undefined;

vaultServers.push({
name,
config,
enabled: meta.enabled ?? DEFAULT_MCP_SERVER.enabled,
contextSaving: meta.contextSaving ?? DEFAULT_MCP_SERVER.contextSaving,
disabledTools: normalizedDisabledTools,
description: meta.description,
});
}
}
}
} catch {
// Non-critical: return whatever vault servers could be loaded
}

const content = await this.adapter.read(MCP_CONFIG_PATH);
const file = JSON.parse(content) as ClaudianMcpConfigFile;

if (!file.mcpServers || typeof file.mcpServers !== 'object') {
return [];
// Append global servers outside the vault try/catch (vault wins on name collision)
const vaultNames = new Set(vaultServers.map(s => s.name));
for (const globalServer of this.loadGlobal()) {
if (!vaultNames.has(globalServer.name)) {
vaultServers.push(globalServer);
}
}

const claudianMeta = file._claudian?.servers ?? {};
const servers: ClaudianMcpServer[] = [];
return vaultServers;
}

for (const [name, config] of Object.entries(file.mcpServers)) {
if (!isValidMcpServerConfig(config)) {
continue;
}

const meta = claudianMeta[name] ?? {};
const disabledTools = Array.isArray(meta.disabledTools)
? meta.disabledTools.filter((tool) => typeof tool === 'string')
: undefined;
const normalizedDisabledTools =
disabledTools && disabledTools.length > 0 ? disabledTools : undefined;
/**
* Load MCP servers from the user-level ~/.claude/settings.json.
* These are read-only from Claudian's perspective (CC owns the file).
*/
private loadGlobal(): ClaudianMcpServer[] {
try {
if (!fs.existsSync(GLOBAL_MCP_SETTINGS_PATH)) return [];

servers.push({
const content = fs.readFileSync(GLOBAL_MCP_SETTINGS_PATH, 'utf-8');
const settings = JSON.parse(content) as Record<string, unknown>;

const mcpServers = settings.mcpServers;
if (!mcpServers || typeof mcpServers !== 'object' || Array.isArray(mcpServers)) {
return [];
}

const result: ClaudianMcpServer[] = [];
for (const [name, config] of Object.entries(mcpServers as Record<string, unknown>)) {
if (!isValidMcpServerConfig(config)) continue;
result.push({
name,
config,
enabled: meta.enabled ?? DEFAULT_MCP_SERVER.enabled,
contextSaving: meta.contextSaving ?? DEFAULT_MCP_SERVER.contextSaving,
disabledTools: normalizedDisabledTools,
description: meta.description,
config: config as McpServerConfig,
enabled: DEFAULT_MCP_SERVER.enabled,
contextSaving: DEFAULT_MCP_SERVER.contextSaving,
});
}

return servers;
return result;
} catch {
// Non-critical: global settings file may be absent or malformed
return [];
}
}
Expand Down
71 changes: 59 additions & 12 deletions src/core/storage/SkillStorage.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,89 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';

import { parsedToSlashCommand, parseSlashCommandContent, serializeCommand } from '../../utils/slashCommand';
import type { SlashCommand } from '../types';
import type { VaultFileAdapter } from './VaultFileAdapter';

export const SKILLS_PATH = '.claude/skills';
export const GLOBAL_SKILLS_DIR = path.join(os.homedir(), '.claude', 'skills');

export class SkillStorage {
constructor(private adapter: VaultFileAdapter) {}
constructor(private adapter: VaultFileAdapter) { }

async loadAll(): Promise<SlashCommand[]> {
const skills: SlashCommand[] = [];
const loadedNames = new Set<string>();

try {
if (await this.adapter.exists(SKILLS_PATH)) {
const folders = await this.adapter.listFolders(SKILLS_PATH);

for (const folder of folders) {
const skillName = folder.split('/').pop()!;
const skillPath = `${SKILLS_PATH}/${skillName}/SKILL.md`;

try {
if (!(await this.adapter.exists(skillPath))) continue;

const content = await this.adapter.read(skillPath);
const parsed = parseSlashCommandContent(content);

skills.push(parsedToSlashCommand(parsed, {
id: `skill-${skillName}`,
name: skillName,
source: 'user',
}));
loadedNames.add(skillName);
} catch {
// Non-critical: skip malformed skill files
}
}
}
} catch {
// Non-critical: skip vault skills if directory missing or inaccessible
}

// Also load user-level skills from ~/.claude/skills (global Claude Code skills).
// Vault skills take precedence: if a skill with the same name already loaded, skip.
this.loadGlobalSkills(skills, loadedNames);

return skills;
}

private loadGlobalSkills(skills: SlashCommand[], loadedNames: Set<string>): void {
try {
const folders = await this.adapter.listFolders(SKILLS_PATH);
if (!fs.existsSync(GLOBAL_SKILLS_DIR)) return;

for (const folder of folders) {
const skillName = folder.split('/').pop()!;
const skillPath = `${SKILLS_PATH}/${skillName}/SKILL.md`;
const entries = fs.readdirSync(GLOBAL_SKILLS_DIR, { withFileTypes: true });

for (const entry of entries) {
if (!entry.isDirectory()) continue;

const skillName = entry.name;
if (loadedNames.has(skillName)) continue; // Vault skill wins

const skillPath = path.join(GLOBAL_SKILLS_DIR, skillName, 'SKILL.md');

try {
if (!(await this.adapter.exists(skillPath))) continue;
if (!fs.existsSync(skillPath)) continue;

const content = await this.adapter.read(skillPath);
const content = fs.readFileSync(skillPath, 'utf-8');
const parsed = parseSlashCommandContent(content);

skills.push(parsedToSlashCommand(parsed, {
id: `skill-${skillName}`,
id: `skill-global-${skillName}`,
name: skillName,
source: 'user',
}));
} catch {
// Non-critical: skip malformed skill files
// Non-critical: skip malformed global skill files
}
}
} catch {
return [];
// Non-critical: global skills directory may be inaccessible
}

return skills;
}

async save(skill: SlashCommand): Promise<void> {
Expand Down
4 changes: 2 additions & 2 deletions src/core/storage/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ export {
ClaudianSettingsStorage,
type StoredClaudianSettings,
} from './ClaudianSettingsStorage';
export { MCP_CONFIG_PATH, McpStorage } from './McpStorage';
export { GLOBAL_MCP_SETTINGS_PATH, MCP_CONFIG_PATH, McpStorage } from './McpStorage';
export { SESSIONS_PATH, SessionStorage } from './SessionStorage';
export { SKILLS_PATH, SkillStorage } from './SkillStorage';
export { GLOBAL_SKILLS_DIR, SKILLS_PATH, SkillStorage } from './SkillStorage';
export { COMMANDS_PATH, SlashCommandStorage } from './SlashCommandStorage';
export {
CLAUDE_PATH,
Expand Down
Loading