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
12 changes: 12 additions & 0 deletions .beads/issues.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{"id":"obsidian-mcp-tools-web-1","title":"Review MCP SDK documentation for SSE transport setup","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-15T14:26:29.446541-07:00","updated_at":"2025-10-15T14:29:34.027799-07:00","closed_at":"2025-10-15T14:29:34.027799-07:00"}
{"id":"obsidian-mcp-tools-web-10","title":"Apply authentication middleware to /sse and /message endpoints","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-15T15:25:09.410188-07:00","updated_at":"2025-10-15T15:26:19.509838-07:00","closed_at":"2025-10-15T15:26:19.509838-07:00"}
{"id":"obsidian-mcp-tools-web-11","title":"Test API key authentication with valid and invalid keys","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-15T15:25:09.600649-07:00","updated_at":"2025-10-15T15:26:32.591401-07:00","closed_at":"2025-10-15T15:26:32.591401-07:00"}
{"id":"obsidian-mcp-tools-web-12","title":"Add OAuth client credentials support as alternative to API keys","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-15T16:08:03.526023-07:00","updated_at":"2025-10-15T16:33:10.683928-07:00","closed_at":"2025-10-15T16:33:10.683928-07:00"}
{"id":"obsidian-mcp-tools-web-2","title":"Install required HTTP server dependencies (express, cors, etc.)","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-15T14:26:33.960778-07:00","updated_at":"2025-10-15T14:29:47.716515-07:00","closed_at":"2025-10-15T14:29:47.716515-07:00"}
{"id":"obsidian-mcp-tools-web-3","title":"Update ObsidianMcpServer class to use SSEServerTransport","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-15T14:26:33.982785-07:00","updated_at":"2025-10-15T14:32:15.473897-07:00","closed_at":"2025-10-15T14:32:15.473897-07:00"}
{"id":"obsidian-mcp-tools-web-4","title":"Create HTTP server with Express to handle SSE connections","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-15T14:26:34.000322-07:00","updated_at":"2025-10-15T14:32:37.75807-07:00","closed_at":"2025-10-15T14:32:37.75807-07:00"}
{"id":"obsidian-mcp-tools-web-5","title":"Add environment variable for port configuration (default: 3000)","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-15T14:26:34.019946-07:00","updated_at":"2025-10-15T14:32:37.779344-07:00","closed_at":"2025-10-15T14:32:37.779344-07:00"}
{"id":"obsidian-mcp-tools-web-6","title":"Update main index.ts to initialize HTTP server","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-15T14:26:34.03694-07:00","updated_at":"2025-10-15T14:32:37.800366-07:00","closed_at":"2025-10-15T14:32:37.800366-07:00"}
{"id":"obsidian-mcp-tools-web-7","title":"Test the HTTP server to ensure it works correctly","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-15T14:26:34.056719-07:00","updated_at":"2025-10-15T14:34:21.296973-07:00","closed_at":"2025-10-15T14:34:21.296973-07:00"}
{"id":"obsidian-mcp-tools-web-8","title":"Create E2E tests for HTTP server functionality","description":"","status":"closed","priority":2,"issue_type":"task","created_at":"2025-10-15T14:33:29.11052-07:00","updated_at":"2025-10-15T14:34:21.315807-07:00","closed_at":"2025-10-15T14:34:21.315807-07:00"}
{"id":"obsidian-mcp-tools-web-9","title":"Add API key authentication middleware to protect MCP server endpoints","description":"","status":"closed","priority":1,"issue_type":"task","created_at":"2025-10-15T15:25:09.212158-07:00","updated_at":"2025-10-15T15:26:13.644224-07:00","closed_at":"2025-10-15T15:26:13.644224-07:00"}
Binary file added .beads/obsidian-mcp-tools-web.db
Binary file not shown.
21 changes: 2 additions & 19 deletions .clinerules β†’ AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
Use `bd` (beads) for all new work. DO NOT USE MARKDOWN TO TRACK ISSUES.

# Project Architecture

## Structure
Expand Down Expand Up @@ -140,25 +142,6 @@ type({
- Keep README.md in sync with new capabilities
- Maintain changelog entries in CHANGELOG.md

## Task Summary Records

When starting a task:

- Create a new Markdown file in /cline_docs
- Record the initial objective
- Create a checklist of subtasks

Maintain the task file:

- Update the checklist after completing subtasks
- Record what worked and didn't work

When completing a task:

- Summarize the task outcome
- Verify the initial objective was completed
- Record final insights

## Testing Standards

- Unit tests required for business logic
Expand Down
160 changes: 98 additions & 62 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions packages/mcp-server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,17 @@
"acorn": "^8.14.0",
"acorn-walk": "^8.3.4",
"arktype": "2.0.0-rc.30",
"cors": "^2.8.5",
"express": "^5.1.0",
"radash": "^12.1.0",
"shared": "workspace:*",
"turndown": "^7.2.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@types/bun": "latest",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/turndown": "^5.0.5",
"prettier": "^3.4.2",
"typescript": "^5.3.3"
Expand Down
82 changes: 75 additions & 7 deletions packages/mcp-server/src/features/core/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { logger, type ToolRegistry, ToolRegistryClass } from "$/shared";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { registerFetchTool } from "../fetch";
import { registerLocalRestApiTools } from "../local-rest-api";
import { setupObsidianPrompts } from "../prompts";
Expand All @@ -10,10 +10,12 @@ import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js";
import type { IncomingMessage, ServerResponse } from "node:http";

export class ObsidianMcpServer {
private server: Server;
private tools: ToolRegistry;
private transports: Map<string, SSEServerTransport>;

constructor() {
this.server = new Server(
Expand All @@ -30,6 +32,7 @@ export class ObsidianMcpServer {
);

this.tools = new ToolRegistryClass();
this.transports = new Map();

this.setupHandlers();

Expand Down Expand Up @@ -63,17 +66,82 @@ export class ObsidianMcpServer {
});
}

async run() {
logger.debug("Starting server...");
const transport = new StdioServerTransport();
/**
* Handles SSE connection requests (GET /sse)
*/
async handleSSEConnection(req: IncomingMessage, res: ServerResponse) {
logger.debug("New SSE connection request");

const transport = new SSEServerTransport("/message", res);
const sessionId = transport.sessionId;

this.transports.set(sessionId, transport);

transport.onclose = () => {
logger.debug("SSE transport closed", { sessionId });
this.transports.delete(sessionId);
};

transport.onerror = (error) => {
logger.error("SSE transport error", { sessionId, error });
this.transports.delete(sessionId);
};

try {
await this.server.connect(transport);
logger.debug("Server started successfully");
logger.debug("SSE connection established", { sessionId });
} catch (err) {
logger.error("Failed to establish SSE connection", {
error: err instanceof Error ? err.message : String(err),
});
this.transports.delete(sessionId);
throw err;
}
}

/**
* Handles POST message requests (/message)
*/
async handlePostMessage(req: IncomingMessage, res: ServerResponse) {
console.log("handlePostMessage");
const sessionId = new URL(
req.url || "",
`http://${req.headers.host}`,
).searchParams.get("sessionId");

if (!sessionId) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Missing sessionId parameter" }));
return;
}

const transport = this.transports.get(sessionId);
if (!transport) {
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Session not found" }));
return;
}

try {
let body = "";
req.on("data", (chunk) => {
body += chunk.toString();
});

await new Promise<void>((resolve, reject) => {
req.on("end", () => resolve());
req.on("error", reject);
});

const parsedBody = JSON.parse(body);
await transport.handlePostMessage(req, res, parsedBody);
} catch (err) {
logger.fatal("Failed to start server", {
logger.error("Failed to handle POST message", {
error: err instanceof Error ? err.message : String(err),
sessionId,
});
process.exit(1);
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Internal server error" }));
}
}
}
7 changes: 7 additions & 0 deletions packages/mcp-server/src/features/prompts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,13 @@ export function setupObsidianPrompts(server: Server) {
return { prompts };
} catch (err) {
const error = formatMcpError(err);

// If the Prompts directory doesn't exist, return empty list instead of throwing
if (error.code === ErrorCode.InternalError && error.message.includes("404")) {
logger.debug(`Prompts directory not found, returning empty list`);
return { prompts: [] };
}

logger.error("Error in ListPromptsRequestSchema handler", {
error,
message: error.message,
Expand Down
109 changes: 101 additions & 8 deletions packages/mcp-server/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,113 @@
#!/usr/bin/env bun
import { logger } from "$/shared";
import { logger, createOAuthManagerFromEnv, type OAuthTokenManager } from "$/shared";
import { ObsidianMcpServer } from "./features/core";
import { getVersion } from "./features/version" with { type: "macro" };
import express from "express";
import cors from "cors";

async function main() {
try {
// Verify required environment variables
// Verify required environment variables - either API key OR OAuth credentials
const API_KEY = process.env.OBSIDIAN_API_KEY;
if (!API_KEY) {
throw new Error("OBSIDIAN_API_KEY environment variable is required");
const oauthManager = createOAuthManagerFromEnv();

if (!API_KEY && !oauthManager) {
throw new Error(
"Either OBSIDIAN_API_KEY or OAuth credentials (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_TOKEN_ENDPOINT) must be provided"
);
}

logger.debug("Starting MCP Tools for Obsidian server...");
const server = new ObsidianMcpServer();
await server.run();
logger.debug("MCP Tools for Obsidian server is running");
const PORT = parseInt(process.env.PORT || "3000", 10);

logger.debug("Starting MCP Tools for Obsidian HTTP server...");

const app = express();
const mcpServer = new ObsidianMcpServer();

// Enable CORS for all routes
app.use(cors());

// Authentication middleware - supports both API key and OAuth token
const authenticateApiKey: express.RequestHandler = async (req, res, next) => {
// Check for API key in multiple locations
const providedKey =
req.headers.authorization?.replace(/^Bearer\s+/i, '') ||
req.headers['x-api-key'] ||
req.query.api_key;

// If API key is configured, validate it
if (API_KEY) {
if (providedKey === API_KEY) {
next();
return;
}
}

// If OAuth is configured, validate OAuth token
if (oauthManager) {
try {
const validToken = await oauthManager.getToken();
if (providedKey === validToken) {
next();
return;
}
} catch (error) {
logger.error("OAuth token validation failed", { error });
}
}

// If we get here, authentication failed
logger.warn("Unauthorized request - invalid or missing credentials", {
path: req.path,
hasAuth: !!req.headers.authorization,
hasApiKeyHeader: !!req.headers['x-api-key'],
hasApiKeyQuery: !!req.query.api_key,
hasApiKey: !!API_KEY,
hasOAuth: !!oauthManager
});
res.status(401).json({ error: "Unauthorized - Invalid or missing credentials" });
};

// Health check endpoint (no authentication required)
app.get("/health", (req, res) => {
res.json({ status: "ok", version: getVersion() });
});

// SSE endpoint for establishing connections (protected)
app.get("/sse", authenticateApiKey, async (req, res) => {
try {
await mcpServer.handleSSEConnection(req, res);
} catch (error) {
logger.error("SSE connection error", { error });
if (!res.headersSent) {
res.status(500).json({
error: error instanceof Error ? error.message : String(error),
});
}
}
});

// POST endpoint for receiving messages (protected)
app.post("/message", authenticateApiKey, async (req, res) => {
console.log("Message received", req.body);
try {
await mcpServer.handlePostMessage(req, res);
} catch (error) {
logger.error("Message handling error", { error });
if (!res.headersSent) {
res.status(500).json({
error: error instanceof Error ? error.message : String(error),
});
}
}
});

app.listen(PORT, () => {
logger.info(`MCP Tools for Obsidian HTTP server running on port ${PORT}`);
console.log(`Server running at http://localhost:${PORT}`);
console.log(`SSE endpoint: http://localhost:${PORT}/sse`);
console.log(`Health check: http://localhost:${PORT}/health`);
});
} catch (error) {
logger.fatal("Failed to start server", {
error: error instanceof Error ? error.message : String(error),
Expand Down
Loading