One Cloudflare Worker as a Unified MCP for GitHub, Obsidian, and NotebookLM

A practical note on wrapping GitHub, Obsidian Local REST API, and a NotebookLM local bridge behind one small Cloudflare Worker MCP endpoint.

When you attach tools to an agent, the first question is not “what should I connect?” The earlier question is “where is the boundary?”

This experiment starts from a simple idea: expose GitHub repository operations, local Obsidian vault operations, and NotebookLM notebook operations through one MCP server. Instead of keeping the whole server local, a Cloudflare Worker becomes a thin public entrance.

The shape is:

1
2
3
4
5
Agent
-> Cloudflare Worker /mcp/<secret-token>
-> GitHub REST API
-> Obsidian Local REST API tunnel
-> NotebookLM local bridge tunnel

The Worker is not a server that tries to know everything. It is a narrow router. It receives JSON-RPC-shaped MCP calls, then forwards each tool call to the right downstream boundary.

Hiding the Public Endpoint

The MCP endpoint is available only at /mcp/<MCP_PATH_TOKEN>. If the token is missing or too short, the endpoint is closed.

1
2
3
4
5
6
function isMcpPath(pathname: string, env: Env): boolean {
const token = env.MCP_PATH_TOKEN?.trim();
if (!token || token.length < 24) return false;
const match = pathname.match(/^\/mcp\/([^/]+)\/?$/);
return match !== null && safeEqual(match[1], token);
}

Here, safeEqual is a constant-time comparison helper that reduces timing leakage from string comparison. It is not a replacement for a full authentication model, but if the public URL includes a secret path segment, even the comparison should avoid being casual.

The boundary follows a few rules:

  • Without a configured token, the MCP endpoint is unreachable.
  • Health checks are limited to / and /health.
  • Real tool calls enter through POST /mcp/<secret-token>.
  • Sensitive keys are injected through Worker environment variables, not written into source code.

The Minimal MCP Handshake

When an agent connects, the Worker answers initialize.

1
2
3
4
5
6
7
8
9
10
11
if (body.method === "initialize") {
return new Response(JSON.stringify({
jsonrpc: "2.0",
id: body.id,
result: {
protocolVersion: "2024-11-05",
capabilities: { tools: {} },
serverInfo: { name: "gemini-unified-mcp", version: "2.0.0" },
},
}));
}

This implementation does not open resources, prompts, or broad session state. It starts with tool listing and tool calling. For an experiment, that narrow surface is useful: what is available is explicit, and what has not been designed yet is not accidentally exposed.

Three Tool Groups

tools/list returns three groups of tools.

The GitHub tools are:

  • list_repositories
  • get_file_contents
  • create_or_update_file

The Obsidian tools are:

  • obsidian_search
  • obsidian_read_note
  • obsidian_write_note

The NotebookLM tools are:

  • nlm_list_notebooks
  • nlm_query_notebook
  • nlm_add_source

The names are deliberately plain. A model-facing tool should make the action visible. For MCP, names and descriptions are part of the interface; the model uses them to decide what to call.

GitHub as the Remote Repository Boundary

GitHub calls are collected behind one REST helper.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
async function callGitHubApi<T>(
endpoint: string,
token: string,
options: RequestInit = {}
): Promise<T> {
const url = `https://api.github.com${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
"Accept": "application/vnd.github+json",
"Authorization": `Bearer ${token}`,
"User-Agent": "Cloudflare-Worker-Gemini-MCP",
"X-GitHub-Api-Version": "2022-11-28",
...options.headers,
},
});

if (!response.ok) {
throw new Error(`GitHub API Error [${response.status}]: ${await response.text()}`);
}

return response.json() as Promise<T>;
}

The read tool decodes GitHub Contents API base64 responses. The write tool creates a file when no sha is supplied and updates an existing file when sha is present. Exposing that difference in the tool schema helps the agent learn the sequence: read first, capture the sha, then update.

Obsidian as the Local Knowledge Boundary

Obsidian Local REST API usually lives inside the local machine or local network. For a Worker to reach it, a tunnel URL is needed. The Worker receives OBSIDIAN_TUNNEL_URL and OBSIDIAN_API_KEY through environment variables.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
async function callObsidianApi(
path: string,
env: Env,
options: RequestInit = {}
): Promise<Response> {
if (!env.OBSIDIAN_TUNNEL_URL || !env.OBSIDIAN_API_KEY) {
throw new Error("OBSIDIAN_TUNNEL_URL or OBSIDIAN_API_KEY is not configured.");
}

const base = new URL(env.OBSIDIAN_TUNNEL_URL);
const targetUrl = new URL(path.startsWith("/") ? path : `/${path}`, base.origin);

const headers = new Headers(options.headers || {});
headers.set("Authorization", `Bearer ${env.OBSIDIAN_API_KEY}`);
headers.set("Host", base.host);

return fetch(targetUrl.toString(), { ...options, headers });
}

The important choice is restraint. The server does not try to abstract the whole vault. It opens only search, read, and write. The agent can work with the local knowledge base, but every path and every body remains explicit at call time.

NotebookLM as the Bridge Boundary

NotebookLM is not as simple as a direct REST API call. The Worker sends /call requests to a local nlm_bridge.py, and the bridge handles the actual NotebookLM operation.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
async function callNlmBridge(payload: Record<string, unknown>, env: Env): Promise<string> {
if (!env.NLM_TUNNEL_URL || !env.NLM_BRIDGE_SECRET) {
throw new Error("NLM_TUNNEL_URL or NLM_BRIDGE_SECRET is not configured.");
}
const target = new URL("/call", new URL(env.NLM_TUNNEL_URL).origin);
const res = await fetch(target.toString(), {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Bridge-Secret": env.NLM_BRIDGE_SECRET.trim(),
},
body: JSON.stringify(payload),
});
const text = await res.text();
if (!res.ok) {
throw new Error(`NotebookLM bridge error [${res.status}]: ${text}`);
}
return (JSON.parse(text) as { result: string }).result;
}

This keeps browser automation and NotebookLM session handling out of the MCP Worker itself. The Worker remains the public entrance; the bridge owns the heavier local operation. Failure also becomes easier to reason about: the Worker can report a bridge failure, while the bridge handles the NotebookLM-specific problem.

What This Leaves Behind

The code is small, but it gives a useful standard for agent tool servers.

First, the public boundary should be small. There is one /mcp/<secret-token> route, and inside it only initialize, tools/list, and tools/call are handled.

Second, secrets stay out of code. GITHUB_TOKEN, OBSIDIAN_API_KEY, NLM_BRIDGE_SECRET, and MCP_PATH_TOKEN are all environment variables.

Third, tools need model-readable names. Whether the implementation uses GitHub Contents API, Obsidian Local REST API, or a NotebookLM bridge, the model-facing surface should use clear verbs and objects.

Fourth, local and remote boundaries should stay distinct. GitHub is a remote API, Obsidian is a local vault exposed through a tunnel, and NotebookLM is a local bridge. Even when these live in one file, the helpers and tool units should stay separated.

An MCP server does not have to begin as a grand platform. A small router is often the better first shape: name the tools carefully, keep secret and permission boundaries closed, and make failures legible. That is already enough to bring local knowledge, remote repositories, and research notebooks onto one work surface.

Comments

댓글

GitHub 계정으로 의견을 남길 수 있습니다. 댓글은 GitHub Discussions에 저장됩니다.