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>
This commit is contained in:
+111
-5
@@ -11,6 +11,10 @@ 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 = {
|
||||
@@ -24,7 +28,15 @@ server:tool("shell", "Execute a shell command", {
|
||||
local result = handle:read('*a')
|
||||
handle:close()
|
||||
return result ~= '' and result or '(no output)'
|
||||
end)
|
||||
end, {
|
||||
annotations = {
|
||||
title = "Run shell",
|
||||
readOnlyHint = false,
|
||||
destructiveHint = true,
|
||||
idempotentHint = false,
|
||||
openWorldHint = true,
|
||||
},
|
||||
})
|
||||
|
||||
server:tool("read_file", "Read a file", {
|
||||
type = "object",
|
||||
@@ -38,7 +50,15 @@ server:tool("read_file", "Read a file", {
|
||||
local content = f:read('*a')
|
||||
f:close()
|
||||
return content
|
||||
end)
|
||||
end, {
|
||||
annotations = {
|
||||
title = "Read file",
|
||||
readOnlyHint = true,
|
||||
destructiveHint = false,
|
||||
idempotentHint = true,
|
||||
openWorldHint = false,
|
||||
},
|
||||
})
|
||||
|
||||
server:tool("write_file", "Write content to a file", {
|
||||
type = "object",
|
||||
@@ -53,7 +73,15 @@ server:tool("write_file", "Write content to a file", {
|
||||
f:write(args.content)
|
||||
f:close()
|
||||
return string.format("Written %d bytes to %s", #args.content, args.path)
|
||||
end)
|
||||
end, {
|
||||
annotations = {
|
||||
title = "Write file",
|
||||
readOnlyHint = false,
|
||||
destructiveHint = true,
|
||||
idempotentHint = true,
|
||||
openWorldHint = false,
|
||||
},
|
||||
})
|
||||
|
||||
server:tool("list_dir", "List directory contents", {
|
||||
type = "object",
|
||||
@@ -67,7 +95,85 @@ server:tool("list_dir", "List directory contents", {
|
||||
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)
|
||||
|
||||
io.stderr:write("Starting lmcp example server...\n")
|
||||
server:run()
|
||||
-- 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
|
||||
|
||||
Reference in New Issue
Block a user