docs: document inline server setup
This commit is contained in:
parent
3aa0c0b1ac
commit
c8404d3872
78
README.md
78
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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<void> {
|
||||
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<CallResult>)(
|
||||
"react",
|
||||
);
|
||||
const context7 = createServerProxy(mcpRuntime, "context7") as Record<string, unknown>;
|
||||
const resolveLibraryId = context7.resolveLibraryId as (
|
||||
args: unknown,
|
||||
) => Promise<CallResult>;
|
||||
const getLibraryDocs = context7.getLibraryDocs as (
|
||||
args: unknown,
|
||||
) => Promise<CallResult>;
|
||||
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown> {
|
||||
const proxy = createServerProxy(runtime, "context7") as Record<string, unknown>;
|
||||
|
||||
const resolveLibraryId = proxy.resolveLibraryId as (
|
||||
args: unknown,
|
||||
) => Promise<CallResult>;
|
||||
const getLibraryDocs = proxy.getLibraryDocs as (
|
||||
args: unknown,
|
||||
) => Promise<CallResult>;
|
||||
|
||||
const getDocs = async (input: string | GetDocsArgs): Promise<CallResult> => {
|
||||
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<string, unknown> = {
|
||||
context7CompatibleLibraryID: libraryId,
|
||||
...(docArgs ?? {}),
|
||||
};
|
||||
return getLibraryDocs(args);
|
||||
};
|
||||
|
||||
return {
|
||||
...proxy,
|
||||
getDocs,
|
||||
resolveLibraryId,
|
||||
getLibraryDocs,
|
||||
};
|
||||
}
|
||||
@ -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<string, string> };
|
||||
authProvider?: unknown;
|
||||
};
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
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<typeof vi.fn>;
|
||||
};
|
||||
expect(streamableTransport.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user