From d1d7058d3925380e6ea5c31c0ea11ad77ead1af7 Mon Sep 17 00:00:00 2001 From: Ben Cherry Date: Mon, 2 Mar 2026 16:17:13 -0800 Subject: [PATCH 1/9] search --- cmd/lk/docs.go | 669 +++++++++++++++++++++++++++++++++++++++++++++++++ cmd/lk/main.go | 1 + 2 files changed, 670 insertions(+) create mode 100644 cmd/lk/docs.go diff --git a/cmd/lk/docs.go b/cmd/lk/docs.go new file mode 100644 index 00000000..aaeb2b7e --- /dev/null +++ b/cmd/lk/docs.go @@ -0,0 +1,669 @@ +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync/atomic" + + "github.com/urfave/cli/v3" + + livekitcli "github.com/livekit/livekit-cli/v2" +) + +const defaultDocsServerURL = "https://docs.livekit.io/mcp/" + +var ( + DocsCommands = []*cli.Command{ + { + Name: "docs", + Usage: "Search and browse LiveKit documentation", + Description: `Query the LiveKit documentation directly from the terminal. Powered by +the LiveKit docs MCP server (https://docs.livekit.io/mcp). + +Typical workflow: + + 1. Start with an overview of the docs site: + lk docs overview + + 2. Search for a topic: + lk docs search "voice agents" + + 3. Fetch a specific page to read: + lk docs get-page /agents/start/voice-ai-quickstart + + 4. Search for code across LiveKit repositories: + lk docs code-search "class AgentSession" --repo livekit/agents + +All output is rendered as markdown.`, + Commands: []*cli.Command{ + { + Name: "overview", + Usage: "Get a complete overview of the documentation site and table of contents", + Description: `Returns the full docs site table of contents with page descriptions. +This is a great starting point to load context for browsing conceptual +docs rather than relying wholly on search.`, + Action: docsOverview, + }, + { + Name: "search", + Usage: "Search the LiveKit documentation", + ArgsUsage: "[QUERY]", + Description: `Search the docs for a given query. Returns paged results showing page +titles, hierarchical placement, and (sometimes) a content snippet. +Results can then be fetched via "lk docs get-page" for full content. + +The search index covers a large amount of content in many programming +languages. Search should be used as a complement to browsing docs +directly (via "lk docs overview"), not a replacement.`, + Action: docsSearch, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "query", + Aliases: []string{"q"}, + Usage: "Search `QUERY` text", + }, + &cli.IntFlag{ + Name: "page", + Aliases: []string{"p"}, + Usage: "Page number (starts at 0)", + }, + &cli.IntFlag{ + Name: "hits-per-page", + Usage: "Results per page (1-50)", + Value: 10, + }, + }, + }, + { + Name: "get-page", + Aliases: []string{"get-pages"}, + Usage: "Fetch one or more documentation pages as markdown", + ArgsUsage: "PATH [PATH...]", + Description: `Render one or more docs pages to markdown by relative path. Also +supports fetching code from public LiveKit repositories on GitHub. + +Examples: + lk docs get-page /agents/start/voice-ai-quickstart + lk docs get-page /agents/build/tools /agents/build/vision + lk docs get-page https://github.com/livekit/agents/blob/main/README.md + +Note: auto-generated SDK reference pages (e.g. /reference/client-sdk-js) +are hosted externally and cannot be fetched with this command.`, + Action: docsGetPage, + }, + { + Name: "code-search", + Usage: "Search code across LiveKit GitHub repositories", + ArgsUsage: "[QUERY]", + Description: `High-precision GitHub code search across LiveKit repositories. Search +like code, not like English — use actual class names, function names, +and method calls rather than descriptions. Regex is not supported. + +Good queries: "class AgentSession", "def on_enter", "@function_tool" +Bad queries: "how does handoff work", "agent transfer implementation" + +Results come from default branches; very new code in feature branches +may not appear.`, + Action: docsCodeSearch, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "query", + Aliases: []string{"q"}, + Usage: "Search term (use code identifiers, not natural language)", + }, + &cli.StringFlag{ + Name: "repo", + Aliases: []string{"r"}, + Usage: "Target `REPO` (e.g. livekit/agents) or ALL", + Value: "ALL", + }, + &cli.StringFlag{ + Name: "language", + Aliases: []string{"l"}, + Usage: "Language filter (e.g. Python, TypeScript)", + }, + &cli.StringFlag{ + Name: "scope", + Usage: "Search scope: content, filename, or both", + Value: "content", + }, + &cli.IntFlag{ + Name: "limit", + Usage: "Max results to return (1-50)", + Value: 20, + }, + &cli.BoolFlag{ + Name: "full-file", + Usage: "Return full file content instead of snippets", + }, + }, + }, + { + Name: "changelog", + Usage: "Get recent releases and changelog for a LiveKit SDK or package", + ArgsUsage: "IDENTIFIER", + Description: `Get recent releases for a LiveKit repository or package. Supports +repository IDs (e.g. "livekit/agents") and package identifiers +(e.g. "pypi:livekit-agents", "npm:livekit-client", "cargo:livekit"). + +Examples: + lk docs changelog livekit/agents + lk docs changelog pypi:livekit-agents + lk docs changelog npm:@livekit/components-react + lk docs changelog --releases 5 livekit/client-sdk-js`, + Action: docsChangelog, + Flags: []cli.Flag{ + &cli.IntFlag{ + Name: "releases", + Usage: "Number of releases to fetch (1-20)", + Value: 2, + }, + &cli.IntFlag{ + Name: "skip", + Usage: "Number of releases to skip for pagination", + }, + }, + }, + { + Name: "list-sdks", + Usage: "List all LiveKit SDK repositories and package names", + Description: `Returns a list of all LiveKit SDK repositories (client SDKs, server +SDKs, and agent frameworks) with their package names for each platform. +Useful for cross-referencing dependencies and finding the right SDK.`, + Action: docsListSDKs, + }, + { + Name: "submit-feedback", + Usage: "Submit feedback on the LiveKit documentation", + ArgsUsage: "[FEEDBACK]", + Description: `Submit constructive feedback on the LiveKit docs. This feedback is +read by the LiveKit team and used to improve the documentation. +Do not include any personal or proprietary information. + +Examples: + lk docs submit-feedback "The voice agents quickstart needs a Node.js example" + lk docs submit-feedback --page /agents/build/tools "Missing info about error handling"`, + Action: docsSubmitFeedback, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "page", + Usage: "The docs `PAGE` the feedback is about (e.g. /agents/build/tools)", + }, + &cli.StringFlag{ + Name: "feedback", + Aliases: []string{"f"}, + Usage: "Feedback text (max 1024 characters)", + }, + }, + }, + }, + }, + } +) + +// --------------------------------------------------------------------------- +// Command handlers +// --------------------------------------------------------------------------- + +func docsOverview(ctx context.Context, cmd *cli.Command) error { + return callDocsToolAndPrint(ctx, "get_docs_overview", map[string]any{}) +} + +func docsSearch(ctx context.Context, cmd *cli.Command) error { + query := cmd.String("query") + if query == "" && cmd.Args().Len() > 0 { + query = strings.Join(cmd.Args().Slice(), " ") + } + if query == "" { + return cli.ShowSubcommandHelp(cmd) + } + + args := map[string]any{ + "query": query, + } + if p := cmd.Int("page"); p > 0 { + args["page"] = p + } + if hpp := cmd.Int("hits-per-page"); hpp > 0 { + args["hitsPerPage"] = hpp + } + + return callDocsToolAndPrint(ctx, "docs_search", args) +} + +func docsGetPage(ctx context.Context, cmd *cli.Command) error { + if cmd.Args().Len() == 0 { + return cli.ShowSubcommandHelp(cmd) + } + + return callDocsToolAndPrint(ctx, "get_pages", map[string]any{ + "paths": cmd.Args().Slice(), + }) +} + +func docsCodeSearch(ctx context.Context, cmd *cli.Command) error { + query := cmd.String("query") + if query == "" && cmd.Args().Len() > 0 { + query = strings.Join(cmd.Args().Slice(), " ") + } + if query == "" { + return cli.ShowSubcommandHelp(cmd) + } + + args := map[string]any{ + "query": query, + "repo": cmd.String("repo"), + } + if lang := cmd.String("language"); lang != "" { + args["language"] = lang + } + if scope := cmd.String("scope"); scope != "" { + args["scope"] = scope + } + if limit := cmd.Int("limit"); limit > 0 { + args["limit"] = limit + } + if cmd.Bool("full-file") { + args["returnFullFile"] = true + } + + return callDocsToolAndPrint(ctx, "code_search", args) +} + +func docsChangelog(ctx context.Context, cmd *cli.Command) error { + identifier := cmd.Args().First() + if identifier == "" { + return cli.ShowSubcommandHelp(cmd) + } + + args := map[string]any{ + "identifier": identifier, + } + if n := cmd.Int("releases"); n > 0 { + args["releasesToFetch"] = n + } + if s := cmd.Int("skip"); s > 0 { + args["skip"] = s + } + + return callDocsToolAndPrint(ctx, "get_changelog", args) +} + +func docsListSDKs(ctx context.Context, cmd *cli.Command) error { + return callDocsResourceAndPrint(ctx, "livekit://sdks") +} + +func docsSubmitFeedback(ctx context.Context, cmd *cli.Command) error { + feedback := cmd.String("feedback") + if feedback == "" && cmd.Args().Len() > 0 { + feedback = strings.Join(cmd.Args().Slice(), " ") + } + if feedback == "" { + return cli.ShowSubcommandHelp(cmd) + } + + args := map[string]any{ + "feedback": feedback, + } + if page := cmd.String("page"); page != "" { + args["page"] = page + } + + return callDocsToolAndPrint(ctx, "submit_docs_feedback", args) +} + +// --------------------------------------------------------------------------- +// Helpers for calling the MCP server and printing results +// --------------------------------------------------------------------------- + +func callDocsToolAndPrint(ctx context.Context, tool string, args map[string]any) error { + client, err := initDocsClient(ctx) + if err != nil { + return err + } + + result, err := client.callTool(ctx, tool, args) + if err != nil { + if isNotFoundErr(err) { + return fmt.Errorf("%w\n\nhint: the docs server does not recognize the %q tool — try updating your lk CLI to the latest version", err, tool) + } + return err + } + + for _, c := range result.Content { + if c.Type == "text" { + fmt.Println(c.Text) + } + } + return nil +} + +func callDocsResourceAndPrint(ctx context.Context, uri string) error { + client, err := initDocsClient(ctx) + if err != nil { + return err + } + + result, err := client.readResource(ctx, uri) + if err != nil { + if isNotFoundErr(err) { + return fmt.Errorf("%w\n\nhint: the docs server does not recognize the %q resource — try updating your lk CLI to the latest version", err, uri) + } + return err + } + + for _, c := range result.Contents { + if c.Text == "" { + continue + } + text := c.Text + // The server may return the text as a JSON-encoded string; + // attempt to decode it so that newlines render properly. + if strings.HasPrefix(text, "\"") { + var decoded string + if err := json.Unmarshal([]byte(text), &decoded); err == nil { + text = decoded + } + } + fmt.Println(text) + } + return nil +} + +func initDocsClient(ctx context.Context) (*mcpClient, error) { + client := newMCPClient(defaultDocsServerURL) + if err := client.initialize(ctx); err != nil { + return nil, fmt.Errorf("could not connect to the LiveKit docs server: %w", err) + } + return client, nil +} + +// --------------------------------------------------------------------------- +// Error handling +// --------------------------------------------------------------------------- + +// mcpResponseError represents a JSON-RPC error response from the MCP server. +type mcpResponseError struct { + Code int + Message string +} + +func (e *mcpResponseError) Error() string { + return fmt.Sprintf("MCP error %d: %s", e.Code, e.Message) +} + +// isNotFoundErr returns true if the error indicates the server does not +// recognize the requested tool, resource, or method. +func isNotFoundErr(err error) bool { + var rpcErr *mcpResponseError + if !errors.As(err, &rpcErr) { + return false + } + switch rpcErr.Code { + case -32601: // Method not found + return true + case -32602: // Invalid params — may indicate unknown tool + lower := strings.ToLower(rpcErr.Message) + return strings.Contains(lower, "not found") || strings.Contains(lower, "unknown") + default: + return false + } +} + +// --------------------------------------------------------------------------- +// Minimal MCP streamable HTTP client +// --------------------------------------------------------------------------- + +type mcpClient struct { + endpoint string + sessionID string + httpClient *http.Client + nextID atomic.Int64 +} + +func newMCPClient(endpoint string) *mcpClient { + return &mcpClient{ + endpoint: endpoint, + httpClient: &http.Client{}, + } +} + +func (c *mcpClient) initialize(ctx context.Context) error { + _, err := c.sendRequest(ctx, "initialize", map[string]any{ + "protocolVersion": "2024-11-05", + "clientInfo": map[string]string{ + "name": "lk", + "version": livekitcli.Version, + }, + "capabilities": map[string]any{}, + }) + if err != nil { + return err + } + + return c.sendNotification(ctx, "notifications/initialized") +} + +// -- Tool calling ---------------------------------------------------------- + +type mcpToolResult struct { + Content []mcpContent `json:"content"` + IsError bool `json:"isError,omitempty"` +} + +type mcpContent struct { + Type string `json:"type"` + Text string `json:"text"` +} + +func (c *mcpClient) callTool(ctx context.Context, name string, args map[string]any) (*mcpToolResult, error) { + raw, err := c.sendRequest(ctx, "tools/call", map[string]any{ + "name": name, + "arguments": args, + }) + if err != nil { + return nil, err + } + + var result mcpToolResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, fmt.Errorf("failed to parse tool result: %w", err) + } + if result.IsError { + var msg string + for _, c := range result.Content { + if c.Type == "text" { + msg = c.Text + break + } + } + return nil, fmt.Errorf("tool error: %s", msg) + } + return &result, nil +} + +// -- Resource reading ------------------------------------------------------ + +type mcpResourceResult struct { + Contents []mcpResourceContent `json:"contents"` +} + +type mcpResourceContent struct { + URI string `json:"uri"` + Text string `json:"text,omitempty"` + MimeType string `json:"mimeType,omitempty"` +} + +func (c *mcpClient) readResource(ctx context.Context, uri string) (*mcpResourceResult, error) { + raw, err := c.sendRequest(ctx, "resources/read", map[string]any{ + "uri": uri, + }) + if err != nil { + return nil, err + } + + var result mcpResourceResult + if err := json.Unmarshal(raw, &result); err != nil { + return nil, fmt.Errorf("failed to parse resource result: %w", err) + } + return &result, nil +} + +// -- Transport ------------------------------------------------------------- + +// sendNotification sends a JSON-RPC notification (no ID, no response expected). +func (c *mcpClient) sendNotification(ctx context.Context, method string) error { + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "method": method, + }) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + c.setHeaders(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + io.Copy(io.Discard, resp.Body) + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("MCP notification failed with status %d", resp.StatusCode) + } + return nil +} + +// sendRequest sends a JSON-RPC request and returns the result field. +func (c *mcpClient) sendRequest(ctx context.Context, method string, params any) (json.RawMessage, error) { + id := c.nextID.Add(1) + + body, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + }) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, "POST", c.endpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + c.setHeaders(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if sid := resp.Header.Get("Mcp-Session-Id"); sid != "" { + c.sessionID = sid + } + + ct := resp.Header.Get("Content-Type") + if strings.HasPrefix(ct, "text/event-stream") { + return c.readSSE(resp.Body) + } + + return c.readJSON(resp.Body) +} + +func (c *mcpClient) setHeaders(req *http.Request) { + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "text/event-stream, application/json") + if c.sessionID != "" { + req.Header.Set("Mcp-Session-Id", c.sessionID) + } +} + +type jsonRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id,omitempty"` + Result json.RawMessage `json:"result,omitempty"` + Error *jsonRPCError `json:"error,omitempty"` +} + +type jsonRPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (c *mcpClient) readJSON(r io.Reader) (json.RawMessage, error) { + var resp jsonRPCResponse + if err := json.NewDecoder(r).Decode(&resp); err != nil { + return nil, fmt.Errorf("failed to decode MCP response: %w", err) + } + if resp.Error != nil { + return nil, &mcpResponseError{Code: resp.Error.Code, Message: resp.Error.Message} + } + return resp.Result, nil +} + +func (c *mcpClient) readSSE(r io.Reader) (json.RawMessage, error) { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) // up to 10 MB + + var dataLines []string + for scanner.Scan() { + line := scanner.Text() + + switch { + case strings.HasPrefix(line, "data: "): + dataLines = append(dataLines, line[6:]) + case strings.HasPrefix(line, "data:"): + dataLines = append(dataLines, line[5:]) + case line == "" && len(dataLines) > 0: + data := strings.Join(dataLines, "\n") + dataLines = dataLines[:0] + + var resp jsonRPCResponse + if err := json.Unmarshal([]byte(data), &resp); err != nil { + continue + } + // Skip notifications (no ID) + if resp.ID == nil { + continue + } + if resp.Error != nil { + return nil, &mcpResponseError{Code: resp.Error.Code, Message: resp.Error.Message} + } + return resp.Result, nil + } + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("error reading MCP stream: %w", err) + } + + return nil, fmt.Errorf("no response received from MCP server") +} diff --git a/cmd/lk/main.go b/cmd/lk/main.go index faf899ec..26576c29 100644 --- a/cmd/lk/main.go +++ b/cmd/lk/main.go @@ -60,6 +60,7 @@ func main() { app.Commands = append(app.Commands, AppCommands...) app.Commands = append(app.Commands, AgentCommands...) app.Commands = append(app.Commands, CloudCommands...) + app.Commands = append(app.Commands, DocsCommands...) app.Commands = append(app.Commands, ProjectCommands...) app.Commands = append(app.Commands, RoomCommands...) app.Commands = append(app.Commands, TokenCommands...) From 0ea5f1d634027478824afef2f3d5d8678378f5dc Mon Sep 17 00:00:00 2001 From: Ben Cherry Date: Mon, 2 Mar 2026 17:10:49 -0800 Subject: [PATCH 2/9] refactor: use official MCP Go SDK, add missing submit-feedback params Replace hand-rolled MCP streamable HTTP client (~230 lines) with the official modelcontextprotocol/go-sdk. Also add missing --agent and --model flags to submit-feedback to match the server's full schema. Co-Authored-By: Claude Opus 4.6 --- cmd/lk/docs.go | 319 ++++++++----------------------------------------- go.mod | 6 + go.sum | 16 +++ 3 files changed, 75 insertions(+), 266 deletions(-) diff --git a/cmd/lk/docs.go b/cmd/lk/docs.go index aaeb2b7e..e18ecbcb 100644 --- a/cmd/lk/docs.go +++ b/cmd/lk/docs.go @@ -1,4 +1,4 @@ -// Copyright 2024 LiveKit, Inc. +// Copyright 2026 LiveKit, Inc. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -15,17 +15,14 @@ package main import ( - "bufio" - "bytes" "context" "encoding/json" "errors" "fmt" - "io" - "net/http" "strings" - "sync/atomic" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/urfave/cli/v3" livekitcli "github.com/livekit/livekit-cli/v2" @@ -215,6 +212,14 @@ Examples: Aliases: []string{"f"}, Usage: "Feedback text (max 1024 characters)", }, + &cli.StringFlag{ + Name: "agent", + Usage: "Identity of the agent submitting feedback (e.g. \"Cursor\", \"Claude Code\")", + }, + &cli.StringFlag{ + Name: "model", + Usage: "Model `ID` used by the agent (e.g. \"gpt-5\", \"claude-4.5-sonnet\")", + }, }, }, }, @@ -329,6 +334,12 @@ func docsSubmitFeedback(ctx context.Context, cmd *cli.Command) error { if page := cmd.String("page"); page != "" { args["page"] = page } + if agent := cmd.String("agent"); agent != "" { + args["agent"] = agent + } + if model := cmd.String("model"); model != "" { + args["model"] = model + } return callDocsToolAndPrint(ctx, "submit_docs_feedback", args) } @@ -338,34 +349,49 @@ func docsSubmitFeedback(ctx context.Context, cmd *cli.Command) error { // --------------------------------------------------------------------------- func callDocsToolAndPrint(ctx context.Context, tool string, args map[string]any) error { - client, err := initDocsClient(ctx) + session, err := initDocsSession(ctx) if err != nil { return err } + defer session.Close() - result, err := client.callTool(ctx, tool, args) + result, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: tool, + Arguments: args, + }) if err != nil { if isNotFoundErr(err) { return fmt.Errorf("%w\n\nhint: the docs server does not recognize the %q tool — try updating your lk CLI to the latest version", err, tool) } return err } + if result.IsError { + for _, c := range result.Content { + if tc, ok := c.(*mcp.TextContent); ok { + return fmt.Errorf("tool error: %s", tc.Text) + } + } + return fmt.Errorf("tool returned an error") + } for _, c := range result.Content { - if c.Type == "text" { - fmt.Println(c.Text) + if tc, ok := c.(*mcp.TextContent); ok { + fmt.Println(tc.Text) } } return nil } func callDocsResourceAndPrint(ctx context.Context, uri string) error { - client, err := initDocsClient(ctx) + session, err := initDocsSession(ctx) if err != nil { return err } + defer session.Close() - result, err := client.readResource(ctx, uri) + result, err := session.ReadResource(ctx, &mcp.ReadResourceParams{ + URI: uri, + }) if err != nil { if isNotFoundErr(err) { return fmt.Errorf("%w\n\nhint: the docs server does not recognize the %q resource — try updating your lk CLI to the latest version", err, uri) @@ -391,279 +417,40 @@ func callDocsResourceAndPrint(ctx context.Context, uri string) error { return nil } -func initDocsClient(ctx context.Context) (*mcpClient, error) { - client := newMCPClient(defaultDocsServerURL) - if err := client.initialize(ctx); err != nil { +func initDocsSession(ctx context.Context) (*mcp.ClientSession, error) { + client := mcp.NewClient( + &mcp.Implementation{Name: "lk", Version: livekitcli.Version}, + nil, + ) + session, err := client.Connect(ctx, &mcp.StreamableClientTransport{ + Endpoint: defaultDocsServerURL, + }, nil) + if err != nil { return nil, fmt.Errorf("could not connect to the LiveKit docs server: %w", err) } - return client, nil + return session, nil } // --------------------------------------------------------------------------- // Error handling // --------------------------------------------------------------------------- -// mcpResponseError represents a JSON-RPC error response from the MCP server. -type mcpResponseError struct { - Code int - Message string -} - -func (e *mcpResponseError) Error() string { - return fmt.Sprintf("MCP error %d: %s", e.Code, e.Message) -} - // isNotFoundErr returns true if the error indicates the server does not // recognize the requested tool, resource, or method. func isNotFoundErr(err error) bool { - var rpcErr *mcpResponseError + var rpcErr *jsonrpc.Error if !errors.As(err, &rpcErr) { return false } switch rpcErr.Code { - case -32601: // Method not found + case jsonrpc.CodeMethodNotFound: // -32601 + return true + case mcp.CodeResourceNotFound: // -32002 return true - case -32602: // Invalid params — may indicate unknown tool + case jsonrpc.CodeInvalidParams: // -32602 — may indicate unknown tool lower := strings.ToLower(rpcErr.Message) return strings.Contains(lower, "not found") || strings.Contains(lower, "unknown") default: return false } } - -// --------------------------------------------------------------------------- -// Minimal MCP streamable HTTP client -// --------------------------------------------------------------------------- - -type mcpClient struct { - endpoint string - sessionID string - httpClient *http.Client - nextID atomic.Int64 -} - -func newMCPClient(endpoint string) *mcpClient { - return &mcpClient{ - endpoint: endpoint, - httpClient: &http.Client{}, - } -} - -func (c *mcpClient) initialize(ctx context.Context) error { - _, err := c.sendRequest(ctx, "initialize", map[string]any{ - "protocolVersion": "2024-11-05", - "clientInfo": map[string]string{ - "name": "lk", - "version": livekitcli.Version, - }, - "capabilities": map[string]any{}, - }) - if err != nil { - return err - } - - return c.sendNotification(ctx, "notifications/initialized") -} - -// -- Tool calling ---------------------------------------------------------- - -type mcpToolResult struct { - Content []mcpContent `json:"content"` - IsError bool `json:"isError,omitempty"` -} - -type mcpContent struct { - Type string `json:"type"` - Text string `json:"text"` -} - -func (c *mcpClient) callTool(ctx context.Context, name string, args map[string]any) (*mcpToolResult, error) { - raw, err := c.sendRequest(ctx, "tools/call", map[string]any{ - "name": name, - "arguments": args, - }) - if err != nil { - return nil, err - } - - var result mcpToolResult - if err := json.Unmarshal(raw, &result); err != nil { - return nil, fmt.Errorf("failed to parse tool result: %w", err) - } - if result.IsError { - var msg string - for _, c := range result.Content { - if c.Type == "text" { - msg = c.Text - break - } - } - return nil, fmt.Errorf("tool error: %s", msg) - } - return &result, nil -} - -// -- Resource reading ------------------------------------------------------ - -type mcpResourceResult struct { - Contents []mcpResourceContent `json:"contents"` -} - -type mcpResourceContent struct { - URI string `json:"uri"` - Text string `json:"text,omitempty"` - MimeType string `json:"mimeType,omitempty"` -} - -func (c *mcpClient) readResource(ctx context.Context, uri string) (*mcpResourceResult, error) { - raw, err := c.sendRequest(ctx, "resources/read", map[string]any{ - "uri": uri, - }) - if err != nil { - return nil, err - } - - var result mcpResourceResult - if err := json.Unmarshal(raw, &result); err != nil { - return nil, fmt.Errorf("failed to parse resource result: %w", err) - } - return &result, nil -} - -// -- Transport ------------------------------------------------------------- - -// sendNotification sends a JSON-RPC notification (no ID, no response expected). -func (c *mcpClient) sendNotification(ctx context.Context, method string) error { - body, err := json.Marshal(map[string]any{ - "jsonrpc": "2.0", - "method": method, - }) - if err != nil { - return err - } - - req, err := http.NewRequestWithContext(ctx, "POST", c.endpoint, bytes.NewReader(body)) - if err != nil { - return err - } - c.setHeaders(req) - - resp, err := c.httpClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - io.Copy(io.Discard, resp.Body) - - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted && resp.StatusCode != http.StatusNoContent { - return fmt.Errorf("MCP notification failed with status %d", resp.StatusCode) - } - return nil -} - -// sendRequest sends a JSON-RPC request and returns the result field. -func (c *mcpClient) sendRequest(ctx context.Context, method string, params any) (json.RawMessage, error) { - id := c.nextID.Add(1) - - body, err := json.Marshal(map[string]any{ - "jsonrpc": "2.0", - "id": id, - "method": method, - "params": params, - }) - if err != nil { - return nil, err - } - - req, err := http.NewRequestWithContext(ctx, "POST", c.endpoint, bytes.NewReader(body)) - if err != nil { - return nil, err - } - c.setHeaders(req) - - resp, err := c.httpClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if sid := resp.Header.Get("Mcp-Session-Id"); sid != "" { - c.sessionID = sid - } - - ct := resp.Header.Get("Content-Type") - if strings.HasPrefix(ct, "text/event-stream") { - return c.readSSE(resp.Body) - } - - return c.readJSON(resp.Body) -} - -func (c *mcpClient) setHeaders(req *http.Request) { - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "text/event-stream, application/json") - if c.sessionID != "" { - req.Header.Set("Mcp-Session-Id", c.sessionID) - } -} - -type jsonRPCResponse struct { - JSONRPC string `json:"jsonrpc"` - ID any `json:"id,omitempty"` - Result json.RawMessage `json:"result,omitempty"` - Error *jsonRPCError `json:"error,omitempty"` -} - -type jsonRPCError struct { - Code int `json:"code"` - Message string `json:"message"` -} - -func (c *mcpClient) readJSON(r io.Reader) (json.RawMessage, error) { - var resp jsonRPCResponse - if err := json.NewDecoder(r).Decode(&resp); err != nil { - return nil, fmt.Errorf("failed to decode MCP response: %w", err) - } - if resp.Error != nil { - return nil, &mcpResponseError{Code: resp.Error.Code, Message: resp.Error.Message} - } - return resp.Result, nil -} - -func (c *mcpClient) readSSE(r io.Reader) (json.RawMessage, error) { - scanner := bufio.NewScanner(r) - scanner.Buffer(make([]byte, 0, 64*1024), 10*1024*1024) // up to 10 MB - - var dataLines []string - for scanner.Scan() { - line := scanner.Text() - - switch { - case strings.HasPrefix(line, "data: "): - dataLines = append(dataLines, line[6:]) - case strings.HasPrefix(line, "data:"): - dataLines = append(dataLines, line[5:]) - case line == "" && len(dataLines) > 0: - data := strings.Join(dataLines, "\n") - dataLines = dataLines[:0] - - var resp jsonRPCResponse - if err := json.Unmarshal([]byte(data), &resp); err != nil { - continue - } - // Skip notifications (no ID) - if resp.ID == nil { - continue - } - if resp.Error != nil { - return nil, &mcpResponseError{Code: resp.Error.Code, Message: resp.Error.Message} - } - return resp.Result, nil - } - } - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("error reading MCP stream: %w", err) - } - - return nil, fmt.Errorf("no response received from MCP server") -} diff --git a/go.mod b/go.mod index f62fe0e8..4a8b4481 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/livekit/server-sdk-go/v2 v2.14.0 github.com/mattn/go-isatty v0.0.20 github.com/moby/patternmatcher v0.6.0 + github.com/modelcontextprotocol/go-sdk v1.4.0 github.com/pelletier/go-toml v1.9.5 github.com/pion/rtcp v1.2.16 github.com/pion/rtp v1.10.1 @@ -99,6 +100,7 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/google/cel-go v0.27.0 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect @@ -159,6 +161,8 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/sajari/fuzzy v1.0.0 // indirect github.com/secure-systems-lab/go-securesystemslib v0.9.1 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.3 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect @@ -172,6 +176,7 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect @@ -191,6 +196,7 @@ require ( golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/mod v0.33.0 // indirect golang.org/x/net v0.51.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 // indirect diff --git a/go.sum b/go.sum index 72ada4b5..5db77d38 100644 --- a/go.sum +++ b/go.sum @@ -215,6 +215,8 @@ github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= @@ -224,6 +226,8 @@ github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXn github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -313,6 +317,8 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8= +github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -415,6 +421,10 @@ github.com/sebdah/goldie/v2 v2.7.1 h1:PkBHymaYdtvEkZV7TmyqKxdmn5/Vcj+8TpATWZjnG5 github.com/sebdah/goldie/v2 v2.7.1/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= github.com/secure-systems-lab/go-securesystemslib v0.9.1 h1:nZZaNz4DiERIQguNy0cL5qTdn9lR8XKHf4RUyG1Sx3g= github.com/secure-systems-lab/go-securesystemslib v0.9.1/go.mod h1:np53YzT0zXGMv6x4iEWc9Z59uR+x+ndLwCLqPYpLXVU= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= @@ -467,6 +477,8 @@ github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= @@ -539,6 +551,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -589,6 +603,8 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 284b9212864f7ba77761d5562032b3c280e4177d Mon Sep 17 00:00:00 2001 From: Ben Cherry Date: Tue, 3 Mar 2026 10:37:59 -0800 Subject: [PATCH 3/9] feat: warn when docs MCP server is newer than expected Check the server's reported version after connecting. If the major or minor version is newer than what this CLI was built against (currently 1.2.x), print a warning to stderr suggesting the user update lk. Co-Authored-By: Claude Opus 4.6 --- cmd/lk/docs.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/cmd/lk/docs.go b/cmd/lk/docs.go index e18ecbcb..065565f9 100644 --- a/cmd/lk/docs.go +++ b/cmd/lk/docs.go @@ -19,6 +19,8 @@ import ( "encoding/json" "errors" "fmt" + "os" + "strconv" "strings" "github.com/modelcontextprotocol/go-sdk/jsonrpc" @@ -30,6 +32,12 @@ import ( const defaultDocsServerURL = "https://docs.livekit.io/mcp/" +// expectedServerVersion is the major.minor version of the LiveKit docs MCP +// server that this CLI was built against. If the server reports a newer +// major or minor version, a warning is printed to stderr suggesting the +// user update their CLI. +var expectedServerVersion = [2]int{1, 2} + var ( DocsCommands = []*cli.Command{ { @@ -428,9 +436,48 @@ func initDocsSession(ctx context.Context) (*mcp.ClientSession, error) { if err != nil { return nil, fmt.Errorf("could not connect to the LiveKit docs server: %w", err) } + + checkServerVersion(session) return session, nil } +// checkServerVersion prints a warning to stderr if the docs MCP server +// reports a newer major or minor version than what this CLI expects. +func checkServerVersion(session *mcp.ClientSession) { + info := session.InitializeResult() + if info == nil || info.ServerInfo == nil || info.ServerInfo.Version == "" { + return + } + + major, minor, ok := parseMajorMinor(info.ServerInfo.Version) + if !ok { + return + } + if major > expectedServerVersion[0] || (major == expectedServerVersion[0] && minor > expectedServerVersion[1]) { + fmt.Fprintf(os.Stderr, + "warning: the LiveKit docs server is version %s but this CLI was built for %d.%d.x — consider updating lk to the latest version\n\n", + info.ServerInfo.Version, expectedServerVersion[0], expectedServerVersion[1], + ) + } +} + +// parseMajorMinor extracts the first two numeric components from a semver string. +func parseMajorMinor(version string) (major, minor int, ok bool) { + parts := strings.SplitN(version, ".", 3) + if len(parts) < 2 { + return 0, 0, false + } + major, err := strconv.Atoi(parts[0]) + if err != nil { + return 0, 0, false + } + minor, err = strconv.Atoi(parts[1]) + if err != nil { + return 0, 0, false + } + return major, minor, true +} + // --------------------------------------------------------------------------- // Error handling // --------------------------------------------------------------------------- From 99cb411ef23747ef12d15e2a64885ca834c41d82 Mon Sep 17 00:00:00 2001 From: Ben Cherry Date: Tue, 3 Mar 2026 10:48:51 -0800 Subject: [PATCH 4/9] feat: add hidden --server-url and --vercel-header flags for docs dev Add hidden flags to `lk docs` for development against staging or preview deployments of the docs MCP server. Also add a README section documenting the docs feature and its development options. Co-Authored-By: Claude Opus 4.6 --- README.md | 44 ++++++++++++++++++++++++++++++++ cmd/lk/docs.go | 69 +++++++++++++++++++++++++++++++++++++++----------- 2 files changed, 98 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e85dce59..85a889c5 100644 --- a/README.md +++ b/README.md @@ -437,6 +437,50 @@ The above simulates 5 concurrent rooms, where each room has: Once the specified duration is over (or if the load test is manually stopped), the load test statistics will be displayed in the form of a table. +## Browsing documentation + +The CLI includes a built-in `lk docs` command that lets you search and browse the LiveKit documentation directly from the terminal. It's powered by the [LiveKit docs MCP server](https://docs.livekit.io/mcp) using the official [MCP Go SDK](https://github.com/modelcontextprotocol/go-sdk). + +```shell +# Get a complete overview of the docs site +lk docs overview + +# Search the docs +lk docs search "voice agents" + +# Fetch a specific page as markdown +lk docs get-page /agents/start/voice-ai-quickstart + +# Search code across LiveKit GitHub repos +lk docs code-search "class AgentSession" --repo livekit/agents + +# Get recent releases for an SDK +lk docs changelog pypi:livekit-agents + +# List all LiveKit SDKs +lk docs list-sdks + +# Submit feedback on the docs +lk docs submit-feedback --page /agents/build/tools "Missing info about error handling" +``` + +Run `lk docs --help` for full details on each subcommand. + +### Development options + +For development against staging or preview deployments of the docs MCP server, two hidden flags are available on the `lk docs` command: + +- `--server-url URL`: Override the MCP server endpoint (default: `https://docs.livekit.io/mcp/`) +- `--vercel-header VALUE`: Set the `x-vercel-protection-bypass` header, required for accessing private Vercel preview deployments + +```shell +# Use a staging server +lk docs --server-url https://docs-staging.example.com/mcp/ search "agents" + +# Access a private Vercel preview deploy +lk docs --server-url https://docs-abc123.vercel.app/mcp/ --vercel-header overview +``` + ## Additional notes ### Parameter precedence diff --git a/cmd/lk/docs.go b/cmd/lk/docs.go index 065565f9..bc789d5d 100644 --- a/cmd/lk/docs.go +++ b/cmd/lk/docs.go @@ -19,6 +19,7 @@ import ( "encoding/json" "errors" "fmt" + "net/http" "os" "strconv" "strings" @@ -61,6 +62,16 @@ Typical workflow: lk docs code-search "class AgentSession" --repo livekit/agents All output is rendered as markdown.`, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "server-url", + Hidden: true, + }, + &cli.StringFlag{ + Name: "vercel-header", + Hidden: true, + }, + }, Commands: []*cli.Command{ { Name: "overview", @@ -240,7 +251,7 @@ Examples: // --------------------------------------------------------------------------- func docsOverview(ctx context.Context, cmd *cli.Command) error { - return callDocsToolAndPrint(ctx, "get_docs_overview", map[string]any{}) + return callDocsToolAndPrint(ctx, cmd, "get_docs_overview", map[string]any{}) } func docsSearch(ctx context.Context, cmd *cli.Command) error { @@ -262,7 +273,7 @@ func docsSearch(ctx context.Context, cmd *cli.Command) error { args["hitsPerPage"] = hpp } - return callDocsToolAndPrint(ctx, "docs_search", args) + return callDocsToolAndPrint(ctx, cmd, "docs_search", args) } func docsGetPage(ctx context.Context, cmd *cli.Command) error { @@ -270,7 +281,7 @@ func docsGetPage(ctx context.Context, cmd *cli.Command) error { return cli.ShowSubcommandHelp(cmd) } - return callDocsToolAndPrint(ctx, "get_pages", map[string]any{ + return callDocsToolAndPrint(ctx, cmd, "get_pages", map[string]any{ "paths": cmd.Args().Slice(), }) } @@ -301,7 +312,7 @@ func docsCodeSearch(ctx context.Context, cmd *cli.Command) error { args["returnFullFile"] = true } - return callDocsToolAndPrint(ctx, "code_search", args) + return callDocsToolAndPrint(ctx, cmd, "code_search", args) } func docsChangelog(ctx context.Context, cmd *cli.Command) error { @@ -320,11 +331,11 @@ func docsChangelog(ctx context.Context, cmd *cli.Command) error { args["skip"] = s } - return callDocsToolAndPrint(ctx, "get_changelog", args) + return callDocsToolAndPrint(ctx, cmd, "get_changelog", args) } func docsListSDKs(ctx context.Context, cmd *cli.Command) error { - return callDocsResourceAndPrint(ctx, "livekit://sdks") + return callDocsResourceAndPrint(ctx, cmd, "livekit://sdks") } func docsSubmitFeedback(ctx context.Context, cmd *cli.Command) error { @@ -349,15 +360,15 @@ func docsSubmitFeedback(ctx context.Context, cmd *cli.Command) error { args["model"] = model } - return callDocsToolAndPrint(ctx, "submit_docs_feedback", args) + return callDocsToolAndPrint(ctx, cmd, "submit_docs_feedback", args) } // --------------------------------------------------------------------------- // Helpers for calling the MCP server and printing results // --------------------------------------------------------------------------- -func callDocsToolAndPrint(ctx context.Context, tool string, args map[string]any) error { - session, err := initDocsSession(ctx) +func callDocsToolAndPrint(ctx context.Context, cmd *cli.Command, tool string, args map[string]any) error { + session, err := initDocsSession(ctx, cmd) if err != nil { return err } @@ -390,8 +401,8 @@ func callDocsToolAndPrint(ctx context.Context, tool string, args map[string]any) return nil } -func callDocsResourceAndPrint(ctx context.Context, uri string) error { - session, err := initDocsSession(ctx) +func callDocsResourceAndPrint(ctx context.Context, cmd *cli.Command, uri string) error { + session, err := initDocsSession(ctx, cmd) if err != nil { return err } @@ -425,14 +436,29 @@ func callDocsResourceAndPrint(ctx context.Context, uri string) error { return nil } -func initDocsSession(ctx context.Context) (*mcp.ClientSession, error) { +func initDocsSession(ctx context.Context, cmd *cli.Command) (*mcp.ClientSession, error) { + endpoint := defaultDocsServerURL + if u := cmd.String("server-url"); u != "" { + endpoint = u + } + + transport := &mcp.StreamableClientTransport{ + Endpoint: endpoint, + } + if v := cmd.String("vercel-header"); v != "" { + transport.HTTPClient = &http.Client{ + Transport: &headerTransport{ + base: http.DefaultTransport, + headers: map[string]string{"x-vercel-protection-bypass": v}, + }, + } + } + client := mcp.NewClient( &mcp.Implementation{Name: "lk", Version: livekitcli.Version}, nil, ) - session, err := client.Connect(ctx, &mcp.StreamableClientTransport{ - Endpoint: defaultDocsServerURL, - }, nil) + session, err := client.Connect(ctx, transport, nil) if err != nil { return nil, fmt.Errorf("could not connect to the LiveKit docs server: %w", err) } @@ -441,6 +467,19 @@ func initDocsSession(ctx context.Context) (*mcp.ClientSession, error) { return session, nil } +// headerTransport wraps an http.RoundTripper and injects extra headers. +type headerTransport struct { + base http.RoundTripper + headers map[string]string +} + +func (t *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) { + for k, v := range t.headers { + req.Header.Set(k, v) + } + return t.base.RoundTrip(req) +} + // checkServerVersion prints a warning to stderr if the docs MCP server // reports a newer major or minor version than what this CLI expects. func checkServerVersion(session *mcp.ClientSession) { From 7ee0288b2e651142032232b82197caef8da5659a Mon Sep 17 00:00:00 2001 From: Ben Cherry Date: Tue, 3 Mar 2026 11:02:43 -0800 Subject: [PATCH 5/9] feat: send lk_cli_version and project_id with docs MCP tool calls Injects lightweight telemetry params into every MCP tool call so the docs server can track CLI version and optionally the Cloud project ID. The project ID is resolved silently from the --project flag or default project config and omitted when no project is configured. Co-Authored-By: Claude Opus 4.6 --- cmd/lk/docs.go | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/cmd/lk/docs.go b/cmd/lk/docs.go index bc789d5d..10e5324a 100644 --- a/cmd/lk/docs.go +++ b/cmd/lk/docs.go @@ -29,6 +29,7 @@ import ( "github.com/urfave/cli/v3" livekitcli "github.com/livekit/livekit-cli/v2" + "github.com/livekit/livekit-cli/v2/pkg/config" ) const defaultDocsServerURL = "https://docs.livekit.io/mcp/" @@ -374,6 +375,12 @@ func callDocsToolAndPrint(ctx context.Context, cmd *cli.Command, tool string, ar } defer session.Close() + // Inject lightweight telemetry params for the docs MCP server. + args["lk_cli_version"] = livekitcli.Version + if id := tryLoadProjectID(cmd); id != "" { + args["project_id"] = id + } + result, err := session.CallTool(ctx, &mcp.CallToolParams{ Name: tool, Arguments: args, @@ -467,6 +474,23 @@ func initDocsSession(ctx context.Context, cmd *cli.Command) (*mcp.ClientSession, return session, nil } +// tryLoadProjectID attempts to resolve the current LiveKit Cloud project ID +// without producing any console output. It returns an empty string if no +// project is configured. +func tryLoadProjectID(cmd *cli.Command) string { + // Explicit --project flag (inherited from root). + if name := cmd.String("project"); name != "" { + if pc, err := config.LoadProject(name); err == nil && pc.ProjectId != "" { + return pc.ProjectId + } + } + // Fall back to the default project. + if pc, err := config.LoadDefaultProject(); err == nil && pc.ProjectId != "" { + return pc.ProjectId + } + return "" +} + // headerTransport wraps an http.RoundTripper and injects extra headers. type headerTransport struct { base http.RoundTripper From 105a5f8b1173a7867a34c35e91430b0ea43e38b4 Mon Sep 17 00:00:00 2001 From: Ben Cherry Date: Tue, 3 Mar 2026 11:28:26 -0800 Subject: [PATCH 6/9] feat: add request timeout, unit tests, fix hits-per-page default - Add 30s timeout to all docs MCP requests to prevent hanging if the server is unresponsive. - Remove hardcoded hits-per-page default of 10; let the server default to 20 instead. - Add unit tests for parseMajorMinor, isNotFoundErr, and headerTransport. Co-Authored-By: Claude Opus 4.6 --- cmd/lk/docs.go | 15 ++++- cmd/lk/docs_test.go | 158 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 cmd/lk/docs_test.go diff --git a/cmd/lk/docs.go b/cmd/lk/docs.go index 10e5324a..f93b6d0a 100644 --- a/cmd/lk/docs.go +++ b/cmd/lk/docs.go @@ -23,6 +23,7 @@ import ( "os" "strconv" "strings" + "time" "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -34,6 +35,11 @@ import ( const defaultDocsServerURL = "https://docs.livekit.io/mcp/" +// docsRequestTimeout is the maximum time allowed for a complete docs MCP +// request (connect + call + response). This prevents the CLI from hanging +// indefinitely if the server is unresponsive. +const docsRequestTimeout = 30 * time.Second + // expectedServerVersion is the major.minor version of the LiveKit docs MCP // server that this CLI was built against. If the server reports a newer // major or minor version, a warning is printed to stderr suggesting the @@ -107,8 +113,7 @@ directly (via "lk docs overview"), not a replacement.`, }, &cli.IntFlag{ Name: "hits-per-page", - Usage: "Results per page (1-50)", - Value: 10, + Usage: "Results per page (1-50, default 20)", }, }, }, @@ -369,6 +374,9 @@ func docsSubmitFeedback(ctx context.Context, cmd *cli.Command) error { // --------------------------------------------------------------------------- func callDocsToolAndPrint(ctx context.Context, cmd *cli.Command, tool string, args map[string]any) error { + ctx, cancel := context.WithTimeout(ctx, docsRequestTimeout) + defer cancel() + session, err := initDocsSession(ctx, cmd) if err != nil { return err @@ -409,6 +417,9 @@ func callDocsToolAndPrint(ctx context.Context, cmd *cli.Command, tool string, ar } func callDocsResourceAndPrint(ctx context.Context, cmd *cli.Command, uri string) error { + ctx, cancel := context.WithTimeout(ctx, docsRequestTimeout) + defer cancel() + session, err := initDocsSession(ctx, cmd) if err != nil { return err diff --git a/cmd/lk/docs_test.go b/cmd/lk/docs_test.go new file mode 100644 index 00000000..59bcd9de --- /dev/null +++ b/cmd/lk/docs_test.go @@ -0,0 +1,158 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "errors" + "fmt" + "net/http" + "testing" + + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestParseMajorMinor(t *testing.T) { + tests := []struct { + input string + wantMajor int + wantMinor int + wantOK bool + }{ + {"1.2.3", 1, 2, true}, + {"0.9.0", 0, 9, true}, + {"10.20.30", 10, 20, true}, + {"1.2", 1, 2, true}, + {"1", 0, 0, false}, + {"", 0, 0, false}, + {"abc.def", 0, 0, false}, + {"1.abc", 0, 0, false}, + {"abc.2", 0, 0, false}, + {"1.2.3-beta.1", 1, 2, true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + major, minor, ok := parseMajorMinor(tt.input) + if ok != tt.wantOK { + t.Errorf("parseMajorMinor(%q): ok = %v, want %v", tt.input, ok, tt.wantOK) + return + } + if major != tt.wantMajor { + t.Errorf("parseMajorMinor(%q): major = %d, want %d", tt.input, major, tt.wantMajor) + } + if minor != tt.wantMinor { + t.Errorf("parseMajorMinor(%q): minor = %d, want %d", tt.input, minor, tt.wantMinor) + } + }) + } +} + +func TestIsNotFoundErr(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + { + name: "nil error", + err: nil, + want: false, + }, + { + name: "non-RPC error", + err: errors.New("something went wrong"), + want: false, + }, + { + name: "method not found", + err: &jsonrpc.Error{Code: jsonrpc.CodeMethodNotFound, Message: "method not found"}, + want: true, + }, + { + name: "resource not found", + err: &jsonrpc.Error{Code: mcp.CodeResourceNotFound, Message: "resource not found"}, + want: true, + }, + { + name: "invalid params with not found", + err: &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "tool not found"}, + want: true, + }, + { + name: "invalid params with unknown", + err: &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "Unknown tool"}, + want: true, + }, + { + name: "invalid params unrelated", + err: &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "missing required param"}, + want: false, + }, + { + name: "other RPC error", + err: &jsonrpc.Error{Code: jsonrpc.CodeInternalError, Message: "internal error"}, + want: false, + }, + { + name: "wrapped RPC error", + err: fmt.Errorf("call failed: %w", &jsonrpc.Error{Code: jsonrpc.CodeMethodNotFound, Message: "method not found"}), + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isNotFoundErr(tt.err) + if got != tt.want { + t.Errorf("isNotFoundErr(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} + +func TestHeaderTransport(t *testing.T) { + transport := &headerTransport{ + base: roundTripFunc(func(req *http.Request) (*http.Response, error) { + if got := req.Header.Get("x-custom"); got != "value" { + t.Errorf("header x-custom = %q, want %q", got, "value") + } + if got := req.Header.Get("x-other"); got != "other-value" { + t.Errorf("header x-other = %q, want %q", got, "other-value") + } + return &http.Response{StatusCode: 200}, nil + }), + headers: map[string]string{ + "x-custom": "value", + "x-other": "other-value", + }, + } + + req, _ := http.NewRequest("GET", "https://example.com", nil) + resp, err := transport.RoundTrip(req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != 200 { + t.Errorf("status = %d, want 200", resp.StatusCode) + } +} + +// roundTripFunc adapts a function to the http.RoundTripper interface. +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} From e7e2f381e1ee02c9ce5accd662d8107b0886789a Mon Sep 17 00:00:00 2001 From: Ben Cherry Date: Tue, 3 Mar 2026 11:44:43 -0800 Subject: [PATCH 7/9] feat: add --json flag to docs commands Adds a --json / -j flag on the docs parent command so all tool-based subcommands can request JSON output from the MCP server. When set, sends format: "json" in the tool call arguments. Co-Authored-By: Claude Opus 4.6 --- cmd/lk/docs.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cmd/lk/docs.go b/cmd/lk/docs.go index f93b6d0a..4e1af6c9 100644 --- a/cmd/lk/docs.go +++ b/cmd/lk/docs.go @@ -70,6 +70,11 @@ Typical workflow: All output is rendered as markdown.`, Flags: []cli.Flag{ + &cli.BoolFlag{ + Name: "json", + Aliases: []string{"j"}, + Usage: "Output as JSON instead of markdown", + }, &cli.StringFlag{ Name: "server-url", Hidden: true, @@ -388,6 +393,9 @@ func callDocsToolAndPrint(ctx context.Context, cmd *cli.Command, tool string, ar if id := tryLoadProjectID(cmd); id != "" { args["project_id"] = id } + if cmd.Bool("json") { + args["format"] = "json" + } result, err := session.CallTool(ctx, &mcp.CallToolParams{ Name: tool, From 5a4633decb50c06ff48bdc6267dd6a899a4f9ea0 Mon Sep 17 00:00:00 2001 From: Ben Cherry Date: Tue, 3 Mar 2026 17:20:23 -0800 Subject: [PATCH 8/9] feat: switch list-sdks to get_sdks tool, bump expected server to 1.3 Replace the livekit://sdks resource read with the new get_sdks tool call, which supports all standard params (format, telemetry). Remove the now-unused callDocsResourceAndPrint helper and encoding/json import. Bump expectedServerVersion to 1.3. Co-Authored-By: Claude Opus 4.6 --- cmd/lk/docs.go | 43 ++----------------------------------------- 1 file changed, 2 insertions(+), 41 deletions(-) diff --git a/cmd/lk/docs.go b/cmd/lk/docs.go index 4e1af6c9..35013de5 100644 --- a/cmd/lk/docs.go +++ b/cmd/lk/docs.go @@ -16,7 +16,6 @@ package main import ( "context" - "encoding/json" "errors" "fmt" "net/http" @@ -44,7 +43,7 @@ const docsRequestTimeout = 30 * time.Second // server that this CLI was built against. If the server reports a newer // major or minor version, a warning is printed to stderr suggesting the // user update their CLI. -var expectedServerVersion = [2]int{1, 2} +var expectedServerVersion = [2]int{1, 3} var ( DocsCommands = []*cli.Command{ @@ -346,7 +345,7 @@ func docsChangelog(ctx context.Context, cmd *cli.Command) error { } func docsListSDKs(ctx context.Context, cmd *cli.Command) error { - return callDocsResourceAndPrint(ctx, cmd, "livekit://sdks") + return callDocsToolAndPrint(ctx, cmd, "get_sdks", map[string]any{}) } func docsSubmitFeedback(ctx context.Context, cmd *cli.Command) error { @@ -424,44 +423,6 @@ func callDocsToolAndPrint(ctx context.Context, cmd *cli.Command, tool string, ar return nil } -func callDocsResourceAndPrint(ctx context.Context, cmd *cli.Command, uri string) error { - ctx, cancel := context.WithTimeout(ctx, docsRequestTimeout) - defer cancel() - - session, err := initDocsSession(ctx, cmd) - if err != nil { - return err - } - defer session.Close() - - result, err := session.ReadResource(ctx, &mcp.ReadResourceParams{ - URI: uri, - }) - if err != nil { - if isNotFoundErr(err) { - return fmt.Errorf("%w\n\nhint: the docs server does not recognize the %q resource — try updating your lk CLI to the latest version", err, uri) - } - return err - } - - for _, c := range result.Contents { - if c.Text == "" { - continue - } - text := c.Text - // The server may return the text as a JSON-encoded string; - // attempt to decode it so that newlines render properly. - if strings.HasPrefix(text, "\"") { - var decoded string - if err := json.Unmarshal([]byte(text), &decoded); err == nil { - text = decoded - } - } - fmt.Println(text) - } - return nil -} - func initDocsSession(ctx context.Context, cmd *cli.Command) (*mcp.ClientSession, error) { endpoint := defaultDocsServerURL if u := cmd.String("server-url"); u != "" { From 98a0746b09007233c2308507987ffb46ce639e72 Mon Sep 17 00:00:00 2001 From: Ben Cherry Date: Tue, 3 Mar 2026 17:21:47 -0800 Subject: [PATCH 9/9] fix: use camelCase for telemetry params (lkCliVersion, projectId) Match the server-side naming convention. Co-Authored-By: Claude Opus 4.6 --- cmd/lk/docs.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/lk/docs.go b/cmd/lk/docs.go index 35013de5..dec431f0 100644 --- a/cmd/lk/docs.go +++ b/cmd/lk/docs.go @@ -388,9 +388,9 @@ func callDocsToolAndPrint(ctx context.Context, cmd *cli.Command, tool string, ar defer session.Close() // Inject lightweight telemetry params for the docs MCP server. - args["lk_cli_version"] = livekitcli.Version + args["lkCliVersion"] = livekitcli.Version if id := tryLoadProjectID(cmd); id != "" { - args["project_id"] = id + args["projectId"] = id } if cmd.Bool("json") { args["format"] = "json"