Files
lmcp/example_server.lua
T
test0r deb73d129e v1.0.0-rc1: full MCP 2025-06-18 surface
Closes 14 issues; lmcp now implements the complete client-facing
surface of MCP spec 2025-06-18.

New primitives:
  - fetch (#3)              HTTP GET/HEAD with bounded body + render chain
  - web_search (#4)         pluggable backend (SearXNG/DDG/Tavily/Brave)
  - Resources (#5)          resources/list, /read, /templates/list + list_changed
  - Prompts (#6)            prompts/list, /get + list_changed
  - Completion (#7)         completion/complete for prompt/template args
  - Logging (#8)            logging/setLevel + notifications/message
  - Sampling (#9)           server-initiated sampling/createMessage
  - Roots (#10)             roots/list + cache + path_in_roots helper

Protocol / wire:
  - Pagination (#12)        cursor on tools|resources|prompts/list
  - Structured tool output (#13)  structuredContent + _meta + protoV bump to 2025-06-18
  - Tool annotations (#14)  readOnlyHint/destructive/idempotent/openWorld on all tools
  - stdio transport (#15)   LMCP_TRANSPORT=stdio for Claude Desktop / IDE clients
  - Streamable HTTP (#16)   select()-based event loop, sessions, persistent SSE,
                            DELETE, heartbeat, server-initiated request helper
  - ping (#19)              now emits result:{} not result:[] via json.empty_object

Cross-cutting fixes:
  - json.lua: UTF-16 surrogate pair combination (emoji/non-BMP CJK round-trip)
  - json.lua: json.empty_object sentinel for spec-correct {} emission
  - handle_request: generic notification suppression (id==nil → return nil)
    eliminates malformed -32601 with id:null on stdio and HTTP transports

Tool annotations backfilled across all registrations:
  - server.lua: 10 tools (shell, shell_bg, read_file, write_file, edit_file,
    list_dir, search_files, fetch, web_search, systeminfo)
  - hub.lua:   8 remote_* tools
  - example_server.lua: 4 demo tools + 3 sample resources + 1 sample prompt
                        + 1 sample completer

Honest limits, filed as follow-up issues:
  - #11 progress + cancellation — gated on #20 (handler concurrency)
  - #18 windows/pkg sync         — stale April-2026 snapshot, packaging decision
  - #20 concurrent handler dispatch — select() loop concurrencies I/O, not
                                      handler execution; synchronous tool
                                      handlers still serialise (shell sleep 3
                                      blocks a parallel ping)

Backwards compatible: every previously-deployed lmcp client (sessionless
POST, HTTP-only, no Mcp-Session-Id awareness) keeps working unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:15:54 +00:00

180 lines
5.9 KiB
Lua

#!/usr/bin/env lua
-- Example lmcp server — shell tools
-- Usage: lua example_server.lua [port]
local dir = arg[0]:match('(.*/)') or './'
package.path = package.path .. ';' .. dir .. '?.lua'
local lmcp = require('lmcp')
local server = lmcp.new("example-tools", {
port = tonumber(arg[1]) or 8080,
})
-- The optional 5th `opts` arg to server:tool carries MCP annotations.
-- Omit it and clients assume the worst (destructive, openWorld) — fine
-- for prototypes; declare annotations once you know each tool's stance.
server:tool("shell", "Execute a shell command", {
type = "object",
properties = {
command = { type = "string", description = "Shell command to execute" },
timeout = { type = "integer", description = "Timeout in seconds", default = 30 },
},
required = { "command" },
}, function(args)
local handle = io.popen(args.command .. ' 2>&1', 'r')
if not handle then return "Error: could not execute command" end
local result = handle:read('*a')
handle:close()
return result ~= '' and result or '(no output)'
end, {
annotations = {
title = "Run shell",
readOnlyHint = false,
destructiveHint = true,
idempotentHint = false,
openWorldHint = true,
},
})
server:tool("read_file", "Read a file", {
type = "object",
properties = {
path = { type = "string", description = "File path to read" },
},
required = { "path" },
}, function(args)
local f = io.open(args.path, 'r')
if not f then return "Error: could not open " .. args.path end
local content = f:read('*a')
f:close()
return content
end, {
annotations = {
title = "Read file",
readOnlyHint = true,
destructiveHint = false,
idempotentHint = true,
openWorldHint = false,
},
})
server:tool("write_file", "Write content to a file", {
type = "object",
properties = {
path = { type = "string", description = "File path to write" },
content = { type = "string", description = "Content to write" },
},
required = { "path", "content" },
}, function(args)
local f = io.open(args.path, 'w')
if not f then return "Error: could not open " .. args.path .. " for writing" end
f:write(args.content)
f:close()
return string.format("Written %d bytes to %s", #args.content, args.path)
end, {
annotations = {
title = "Write file",
readOnlyHint = false,
destructiveHint = true,
idempotentHint = true,
openWorldHint = false,
},
})
server:tool("list_dir", "List directory contents", {
type = "object",
properties = {
path = { type = "string", description = "Directory path", default = "." },
},
}, function(args)
local path = args.path or '.'
local handle = io.popen('ls -1 ' .. path:gsub("'", "'\\''") .. ' 2>&1')
if not handle then return "Error: could not list " .. path end
local result = handle:read('*a')
handle:close()
return result
end, {
annotations = {
title = "List directory",
readOnlyHint = true,
destructiveHint = false,
idempotentHint = true,
openWorldHint = false,
},
})
-- ---- Resources (MCP primitive — see issue #5) ----
-- Tools-only servers force the client to spend a tools/call round-trip
-- for every read. Resources let the client list and read by URI, with a
-- stable identity it can cache and reference in prompts.
server:resource("text://greeting", {
name = "Greeting",
mimeType = "text/plain",
}, function() return "Hello from lmcp!" end)
-- Tiny binary resource: 8-byte PNG signature, demonstrates blob handling.
server:resource("data://lmcp.png", {
name = "PNG signature",
mimeType = "image/png",
}, function()
return { blob_bytes = "\x89PNG\r\n\x1a\n", mimeType = "image/png" }
end)
-- Template: any local file. `args.path` is captured greedily (no leading
-- slash because the template literal already includes ///).
server:resource_template("file:///{path}", {
name = "Local file",
mimeType = "text/plain",
}, function(args)
local f = io.open("/" .. args.path, "r")
if not f then error("file not found: /" .. args.path) end
local content = f:read("*a"); f:close()
return content
end)
-- ---- Prompts (MCP primitive — see issue #6) ----
-- Parameterised prompt templates the client surfaces as a menu
-- (slash-commands, snippets). Handler returns either a plain string (one
-- user-role text message) or a full { description?, messages = {...} }
-- shape for finer control.
server:prompt("release_note", {
description = "Draft a release note for a given version",
arguments = {
{ name = "version", description = "Tag, e.g. v0.7.1", required = true },
{ name = "since", description = "Previous tag", required = false },
},
}, function(args)
return "Write concise release notes for version " .. (args.version or "?")
.. " since " .. (args.since or "the previous tag")
.. ". Group by category (features / fixes / docs)."
end)
-- Completion for the release_note prompt's `version` argument. Returned
-- list is filtered against `value` (prefix match) by the server's spec
-- contract is "candidates"; clients may further filter.
server:complete("ref/prompt", "release_note", "version", function(value, ctx)
local all = { "v0.5.0", "v0.5.1", "v0.5.2", "v0.5.3", "v0.5.4",
"v0.6.0", "v0.7.0", "v0.7.1", "v1.0.0-rc1" }
if value == "" then return all end
local out = {}
for _, v in ipairs(all) do
if v:sub(1, #value) == value then out[#out + 1] = v end
end
return out
end)
local transport = os.getenv("LMCP_TRANSPORT") or "http"
if transport == "stdio" then
if os.getenv("LMCP_PORT") then
io.stderr:write("lmcp: LMCP_PORT ignored in stdio mode\n")
end
server:run_stdio()
else
io.stderr:write("Starting lmcp example server...\n")
server:run()
end