#!/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