From c8404d387247e11355ed311a3bcdf8efcf0dac09 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 5 Nov 2025 06:11:46 +0000 Subject: [PATCH] docs: document inline server setup --- README.md | 78 ++++++++++++++++++++++++------ docs/spec.md | 3 +- examples/context7-headlines.ts | 52 +++++++++++++++++--- src/context7-client.ts | 86 ---------------------------------- tests/runtime-compose.test.ts | 15 ++++-- 5 files changed, 123 insertions(+), 111 deletions(-) delete mode 100644 src/context7-client.ts diff --git a/README.md b/README.md index fb9141f..846cd72 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,33 @@ const result = await callOnce({ }); ``` +## Configuration Sources + +`mcp-runtime` automatically merges server definitions from multiple tools so your CLI, local IDEs, and agents stay in sync. The default priority is: + +1. `config/mcp_servers.json` in your project +2. Project-scoped `.mcp.json` (Claude Code) +3. Project-scoped `.cursor/mcp.json` +4. Project-scoped `.claude/mcp.json` +5. Claude Desktop config (`claude_desktop_config.json`) +6. Cursor user config (`~/.cursor/mcp.json` or OS equivalent) +7. Codex `~/.codex/config.toml` + +Sources earlier in the list win conflicts. Create `config/mcp_sources.json` to customize the order or add additional files: + +```json +{ + "strategy": "last-wins", + "sources": [ + { "kind": "codex" }, + { "kind": "cursor-user" }, + { "kind": "local-json", "path": "./config/mcp_servers.json" } + ] +} +``` + +Supported `kind` values are `local-json`, `project-mcp-json`, `cursor-project`, `cursor-user`, `claude-project`, `claude-user`, `claude-desktop`, and `codex`. Every entry may override `path` and set `optional: false` when a file must exist. Combine this with environment-variable headers (for example `CONTEXT7_API_KEY`) to keep secrets out of source control while still allowing IDE-specific configs to extend the same runtime. + ## CLI Reference ``` @@ -108,8 +135,24 @@ Prefer the `createServerProxy` helper when you want an ergonomic proxy object fo ```ts import { createRuntime, createServerProxy } from "mcp-runtime"; -const runtime = await createRuntime(); -const context7 = createServerProxy(runtime, "context7"); +const mcpRuntime = await createRuntime({ + servers: [ + { + name: "context7", + description: "Context7 docs MCP", + command: { + kind: "http", + url: new URL("https://mcp.context7.com/mcp"), + headers: process.env.CONTEXT7_API_KEY + ? { Authorization: `Bearer ${process.env.CONTEXT7_API_KEY}` } + : undefined, + }, + }, + ], +}); +// Inline definitions work at runtime; move this block to config/mcp_servers.json if you prefer static config. + +const context7 = createServerProxy(mcpRuntime, "context7"); const search = await context7.resolveLibraryId("react"); const docs = await context7.getLibraryDocs("react"); // maps to required schema fields @@ -117,7 +160,7 @@ const docs = await context7.getLibraryDocs("react"); // maps to required schema console.log(search.text()); // "Available Libraries ..." console.log(docs.markdown()); // markdown excerpt -await runtime.close(); +await mcpRuntime.close(); ``` Every property access maps from camelCase to the underlying tool name automatically (`resolveLibraryId` → `resolve-library-id`). Beyond method names, the proxy: @@ -128,7 +171,7 @@ Every property access maps from camelCase to the underlying tool name automatica - accepts primitives, tuples, or plain objects and routes them onto required schema fields in order (multi-argument tools like Firecrawl’s `scrape` work with positional calls); ```ts -const firecrawl = createServerProxy(runtime, "firecrawl"); +const firecrawl = createServerProxy(mcpRuntime, "firecrawl"); await firecrawl.firecrawlScrape( "https://example.com/docs", ["markdown", "html"], // 2nd required/optional field from schema @@ -141,21 +184,28 @@ You can still drop down to `context7.call("resolve-library-id", { args: { ... } ### High-level helpers -Some servers benefit from composable workflows. `createContext7Client` layers on top of the proxy to resolve an ID and fetch docs in one call: +You can compose higher-level helpers yourself with plain JavaScript. Because the proxy already maps positional arguments to schema fields, a tiny wrapper is enough to resolve a Context7 library ID and fetch the docs: ```ts -import { createContext7Client, createRuntime } from "mcp-runtime"; +const context7 = createServerProxy(mcpRuntime, "context7"); -const runtime = await createRuntime(); -const context7 = createContext7Client(runtime); - -const docs = await context7.getDocs("react"); // resolves ID + downloads markdown -console.log(docs.markdown()); - -await runtime.close(); +async function getContext7Docs(libraryName: string) { + const resolution = await context7.resolveLibraryId(libraryName); + const id = + resolution.json<{ candidates?: Array<{ context7CompatibleLibraryID?: string }> }>() + ?.candidates?.find((candidate) => candidate?.context7CompatibleLibraryID) + ?.context7CompatibleLibraryID ?? + resolution + .text() + ?.match(/Context7-compatible library ID:\s*([^\s]+)/)?.[1]; + if (!id) { + throw new Error(`Context7 library "${libraryName}" not found.`); + } + return context7.getLibraryDocs(id); +} ``` -The helper still returns a `CallResult`, so you can opt into `.json()` / `.text()` as needed. +The returned value is still a `CallResult`, so you can opt into `.markdown()`, `.json()`, etc. ## Testing & CI diff --git a/docs/spec.md b/docs/spec.md index 74b7f74..1500303 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -29,6 +29,7 @@ summary: 'Plan for the mcp-runtime package replacing the Sweetistics pnpm MCP he - Tool signature + schema fetching for `list`. - Provide lazy connection pooling per server to minimize startup cost. - Expose a lightweight server proxy (`createServerProxy`) that maps camelCase method accesses to tool names, fills JSON-schema defaults, validates required arguments, and returns a helper (`CallResult`) for extracting text/markdown/JSON without re-parsing the content envelope. +- Document direct (inline) server definitions passed to `createRuntime({ servers: [...] })`, including env-sourced headers, so agents can bootstrap without touching config files. ## Schema-Aware Proxy Strategy - Cache tool schemas on first access; tolerate failures by falling back to raw `callTool`. @@ -39,7 +40,7 @@ summary: 'Plan for the mcp-runtime package replacing the Sweetistics pnpm MCP he - Merging JSON-schema defaults and validating required fields before dispatch. - Return `CallResult` objects exposing `.raw`, `.text()`, `.markdown()`, `.json()` helpers for consistent post-processing. - Keep implementation generic—no hardcoded knowledge of specific servers—so new MCP servers automatically gain the ergonomic API. -- Surface composability helpers (e.g., `createContext7Client`) that compose multiple proxy calls while still delegating schema parsing to the proxy. +- Encourage lightweight composition helpers in examples (e.g., resolving then fetching Context7 docs) while keeping library exports generic. - Back the proxy with targeted unit tests that cover primitive-only calls, positional tuples + option bags, and error fallbacks when schemas are missing. ## Work Phases diff --git a/examples/context7-headlines.ts b/examples/context7-headlines.ts index d6936d7..0cdcc59 100644 --- a/examples/context7-headlines.ts +++ b/examples/context7-headlines.ts @@ -5,15 +5,38 @@ * and print only the markdown headlines. */ -import { createContext7Client, createRuntime, type CallResult } from "../src/index.js"; +import { createRuntime, createServerProxy, type CallResult } from "../src/index.js"; async function main(): Promise { - const runtime = await createRuntime(); + const apiKey = process.env.CONTEXT7_API_KEY; + const context7Definition = { + name: "context7", + description: "Context7 documentation MCP", + command: { + kind: "http" as const, + url: new URL("https://mcp.context7.com/mcp"), + headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : undefined, + }, + }; + // Inline definitions can also live in config/mcp_servers.json if you prefer shared config. + + const mcpRuntime = await createRuntime({ servers: [context7Definition] }); try { - const context7 = createContext7Client(runtime); - const docs = await (context7.getDocs as (name: string) => Promise)( - "react", - ); + const context7 = createServerProxy(mcpRuntime, "context7") as Record; + const resolveLibraryId = context7.resolveLibraryId as ( + args: unknown, + ) => Promise; + const getLibraryDocs = context7.getLibraryDocs as ( + args: unknown, + ) => Promise; + + const resolved = await resolveLibraryId("react"); + const contextId = extractContext7LibraryId(resolved); + if (!contextId) { + throw new Error("Unable to resolve React documentation ID from Context7."); + } + + const docs = await getLibraryDocs(contextId); const markdown = docs.markdown() ?? docs.text() ?? ""; const headlines = markdown @@ -24,7 +47,7 @@ async function main(): Promise { console.log("# Headlines for React"); console.log(headlines || "(no headlines found)"); } finally { - await runtime.close(); + await mcpRuntime.close(); } } @@ -32,3 +55,18 @@ main().catch((error) => { console.error(error); process.exit(1); }); + +function extractContext7LibraryId(result: CallResult): string | null { + const json = result.json< + { candidates?: Array<{ context7CompatibleLibraryID?: string }> } | undefined + >(); + if (json && json.candidates) { + for (const candidate of json.candidates) { + if (candidate?.context7CompatibleLibraryID) { + return candidate.context7CompatibleLibraryID; + } + } + } + const textMatch = result.text()?.match(/Context7-compatible library ID:\s*([^\s]+)/); + return textMatch?.[1] ?? null; +} diff --git a/src/context7-client.ts b/src/context7-client.ts deleted file mode 100644 index ede7b3a..0000000 --- a/src/context7-client.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { Runtime } from "./runtime.js"; -import { createServerProxy } from "./server-proxy.js"; -import type { CallResult } from "./result-utils.js"; - -export interface GetDocsArgs { - libraryName?: string; - libraryId?: string; - docArgs?: Record; -} - -function extractFirstLibraryId(result: CallResult): string | null { - const json = result.json< - | - { candidates?: Array<{ context7CompatibleLibraryID?: string }> } - | - { results?: Array<{ id?: string }> } - >(); - if (json) { - if (Array.isArray((json as { candidates?: unknown }).candidates)) { - for (const candidate of (json as { - candidates: Array<{ context7CompatibleLibraryID?: string }>; - }).candidates) { - if (candidate?.context7CompatibleLibraryID) { - return candidate.context7CompatibleLibraryID; - } - } - } - if (Array.isArray((json as { results?: unknown }).results)) { - for (const candidate of (json as { results: Array<{ id?: string }> }).results) { - if (candidate?.id) { - return candidate.id; - } - } - } - } - - const text = result.text(); - if (!text) return null; - const match = text.match(/Context7-compatible library ID:\s*([^\s]+)/); - return match?.[1] ?? null; -} - -export function createContext7Client(runtime: Runtime): Record { - const proxy = createServerProxy(runtime, "context7") as Record; - - const resolveLibraryId = proxy.resolveLibraryId as ( - args: unknown, - ) => Promise; - const getLibraryDocs = proxy.getLibraryDocs as ( - args: unknown, - ) => Promise; - - const getDocs = async (input: string | GetDocsArgs): Promise => { - const { libraryName, libraryId: providedId, docArgs } = - typeof input === "string" - ? { libraryName: input, libraryId: undefined, docArgs: undefined } - : input; - - let libraryId = providedId; - if (!libraryId) { - if (!libraryName) { - throw new Error("libraryName is required when libraryId is not provided"); - } - const resolved = await resolveLibraryId({ libraryName }); - libraryId = extractFirstLibraryId(resolved); - if (!libraryId) { - throw new Error( - `Unable to resolve Context7 library ID for "${libraryName}"`, - ); - } - } - - const args: Record = { - context7CompatibleLibraryID: libraryId, - ...(docArgs ?? {}), - }; - return getLibraryDocs(args); - }; - - return { - ...proxy, - getDocs, - resolveLibraryId, - getLibraryDocs, - }; -} diff --git a/tests/runtime-compose.test.ts b/tests/runtime-compose.test.ts index f665be3..f9c7d4c 100644 --- a/tests/runtime-compose.test.ts +++ b/tests/runtime-compose.test.ts @@ -127,9 +127,11 @@ describe("mcp-runtime composability", () => { servers: [ { name: "fake", + description: "Inline fake server", command: { kind: "http" as const, url: new URL("https://example.com"), + headers: { Authorization: "Bearer inline-test" }, }, }, ], @@ -146,6 +148,16 @@ describe("mcp-runtime composability", () => { ]); expect(mocks.connectMock).toHaveBeenCalledTimes(1); expect(mocks.clientInstances).toHaveLength(1); + const streamableTransport = mocks.streamableInstances[0] as { + options?: { + requestInit?: { headers?: Record }; + authProvider?: unknown; + }; + close: ReturnType; + }; + expect(streamableTransport.options?.requestInit?.headers).toEqual({ + Authorization: "Bearer inline-test", + }); const first = await runtime.callTool("fake", "echo", { args: { text: "hi" }, @@ -170,9 +182,6 @@ describe("mcp-runtime composability", () => { await runtime.close(); expect(mocks.streamableInstances).toHaveLength(1); - const streamableTransport = mocks.streamableInstances[0] as { - close: ReturnType; - }; expect(streamableTransport.close).toHaveBeenCalledTimes(1); }); });