From 4b289a8fbb80851723f647b4becd29d05e266853 Mon Sep 17 00:00:00 2001 From: Raezil Date: Sat, 29 Aug 2026 16:54:20 +0200 Subject: [PATCH] Fix test coverage and release metadata --- .github/workflows/ci.yml | 5 ++- CHANGELOG.md | 6 +++ Makefile | 7 +-- README.md | 8 ++-- examples/servers/http_server.py | 3 +- lua/utcp/transports/graphql.lua | 35 ++++++++++++--- lua/utcp/transports/init.lua | 12 +++-- lua/utcp/transports/tcp.lua | 63 +++++++++++++++++++++----- lua/utcp/transports/text.lua | 35 ++++++++++++--- lua/utcp/transports/udp.lua | 55 ++++++++++++++++++++--- tests/run_http_integration.py | 78 +++++++++++++++++++++++++++++++++ tests/test_cli.lua | 4 +- tests/test_http.lua | 30 ++++++++++--- 13 files changed, 291 insertions(+), 50 deletions(-) create mode 100644 tests/run_http_integration.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b0d0912..d310cc6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,6 +28,7 @@ jobs: - name: Install dependencies run: | + luarocks install luasocket luarocks install lua-cjson luarocks install dkjson luarocks install luasec @@ -38,4 +39,6 @@ jobs: lua -e "assert(require('cjson.safe'))" - name: Run tests - run: make test + run: | + make test + make integration diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fc236e..838985e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 1.4.0 + +- Add client-side guard evaluation and approval support for tool calls. +- Add provider-qualified tool lookup and stateful transport caching. +- Improve CodeMode isolation and structured execution errors. + ## 1.3.0 - Cache constructed transports for repeated tool calls and cache the registry's ordered tool list. diff --git a/Makefile b/Makefile index 079d0e0..864999b 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,7 @@ test: $(LUA) tests/test_mcp.lua $(LUA) tests/test_template.lua $(LUA) tests/test_transports.lua + $(LUA) tests/test_cli.lua examples-local: $(LUA) examples/manual.lua @@ -63,15 +64,15 @@ example-codemode-openrouter-refactor-readme: $(LUA) examples/codemode_openrouter_refactor_readme.lua integration: - $(LUA) tests/test_http.lua + python3 tests/run_http_integration.py "$(LUA)" -check: test +check: test integration benchmark: $(LUA) benchmarks/transport_cache.lua zip: - zip -qr lua-utcp.zip lua lua-utcp-1.3-1.rockspec lua-utcp-1.3-1.src.rock README.md Makefile tests examples spec LICENSE NOTICE.md CHANGELOG.md + zip -qr lua-utcp.zip lua lua-utcp-1.4-1.rockspec lua-utcp-1.4-1.src.rock README.md Makefile tests examples spec LICENSE NOTICE.md CHANGELOG.md example-provider-test: $(LUA) tests/test_provider_json.lua diff --git a/README.md b/README.md index e08d770..086552d 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ ### LuaRocks ```sh -luarocks install lua-utcp-1.3-1.rockspec +luarocks install lua-utcp-1.4-1.rockspec ``` ### From Source @@ -316,7 +316,7 @@ Providers can also be defined in JSON and loaded into the canonical registry: ```lua local utcp = require("utcp") -local provider = assert(utcp.load_provider("provider.json")) +local provider = assert(utcp.load_provider("examples/provider.json")) local client = utcp.Client.new() assert(client:add_provider(provider)) @@ -333,7 +333,7 @@ local execution = assert(codemode:call_tool_chain([[ Related examples: -* `provider.json` +* `examples/provider.json` * `examples/provider_flow.lua` * `examples/provider_codemode.lua` @@ -394,7 +394,7 @@ lua-utcp/ ├── tests/ # Unit and transport tests ├── examples/ # Usage and CodeMode examples ├── examples/servers/ # Local demo tool servers -├── provider.json # Example provider definition file +├── examples/provider.json # Example provider definition file ├── Makefile └── lua-utcp-*.rockspec ``` diff --git a/examples/servers/http_server.py b/examples/servers/http_server.py index f5f0919..7212b4e 100644 --- a/examples/servers/http_server.py +++ b/examples/servers/http_server.py @@ -1,9 +1,10 @@ #!/usr/bin/env python3 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json +import os HOST = '127.0.0.1' -PORT = 8080 +PORT = int(os.environ.get('UTCP_HTTP_PORT', '8080')) class Handler(BaseHTTPRequestHandler): diff --git a/lua/utcp/transports/graphql.lua b/lua/utcp/transports/graphql.lua index 63e9448..13de15c 100644 --- a/lua/utcp/transports/graphql.lua +++ b/lua/utcp/transports/graphql.lua @@ -1,8 +1,31 @@ -local http=require('utcp.transports.http'); local M={}; local T={}; T.__index=T -function T.new(cfg) return setmetatable(cfg or {},T) end -function T:call(t,args) - local body={query=t.query or t.document, variables=t.variables or args, operationName=t.operation_name}; return http.new(self):request('POST',t.url or self.url,body,t.headers or self.headers) +local http = require('utcp.transports.http') + +local M = {} +local T = {} +T.__index = T + +function T.new(cfg) + return setmetatable(cfg or {}, T) end -function M.new(cfg) return T.new(cfg) end -M.Transport=T + +function T:call(template_cfg, args) + local body = { + query = template_cfg.query or template_cfg.document, + variables = template_cfg.variables or args, + operationName = template_cfg.operation_name, + } + + return http.new(self):request( + 'POST', + template_cfg.url or self.url, + body, + template_cfg.headers or self.headers + ) +end + +function M.new(cfg) + return T.new(cfg) +end + +M.Transport = T return M diff --git a/lua/utcp/transports/init.lua b/lua/utcp/transports/init.lua index 0a0dbcb..8e1c353 100644 --- a/lua/utcp/transports/init.lua +++ b/lua/utcp/transports/init.lua @@ -1,5 +1,11 @@ return { - http=require('utcp.transports.http'), sse=require('utcp.transports.sse'), streamable=require('utcp.transports.streamable'), - tcp=require('utcp.transports.tcp'), udp=require('utcp.transports.udp'), cli=require('utcp.transports.cli'), text=require('utcp.transports.text'), - graphql=require('utcp.transports.graphql'), mcp=require('utcp.transports.mcp') + http = require('utcp.transports.http'), + sse = require('utcp.transports.sse'), + streamable = require('utcp.transports.streamable'), + tcp = require('utcp.transports.tcp'), + udp = require('utcp.transports.udp'), + cli = require('utcp.transports.cli'), + text = require('utcp.transports.text'), + graphql = require('utcp.transports.graphql'), + mcp = require('utcp.transports.mcp'), } diff --git a/lua/utcp/transports/tcp.lua b/lua/utcp/transports/tcp.lua index 856b310..05b2ea5 100644 --- a/lua/utcp/transports/tcp.lua +++ b/lua/utcp/transports/tcp.lua @@ -1,12 +1,55 @@ -local json=require('utcp.json'); local template=require('utcp.template'); local M={}; local T={}; T.__index=T -function T.new(cfg) return setmetatable(cfg or {},T) end -function T:call(t,args) - local socket=require('socket'); local host=self.host or t.host or '127.0.0.1'; local port=self.port or t.port; assert(port,'tcp port is required') - local c,err=socket.tcp(); if not c then return nil,err end; c:settimeout(self.timeout or 10); local ok,e=c:connect(host,port); if not ok then c:close(); return nil,e end - local payload=t.payload or t.body or args; local s=json.encode(payload); if not s then c:close(); return nil,'cannot encode JSON' end - local frame=t.frame or self.frame or 'line'; if frame=='line' then c:send(s..'\n') else c:send(s) end - local out,er=c:receive(frame=='line' and '*l' or '*a'); c:close(); if not out then return nil,er end; local decoded=json.decode(out); if decoded~=nil then return decoded end; return out +local json = require('utcp.json') + +local M = {} +local T = {} +T.__index = T + +function T.new(cfg) + return setmetatable(cfg or {}, T) end -function M.new(cfg) return T.new(cfg) end -M.Transport=T + +function T:call(template_cfg, args) + local socket = require('socket') + local host = self.host or template_cfg.host or '127.0.0.1' + local port = self.port or template_cfg.port + assert(port, 'tcp port is required') + + local client, err = socket.tcp() + if not client then return nil, err end + + client:settimeout(self.timeout or 10) + local connected, connect_err = client:connect(host, port) + if not connected then + client:close() + return nil, connect_err + end + + local payload = template_cfg.payload or template_cfg.body or args + local encoded = json.encode(payload) + if not encoded then + client:close() + return nil, 'cannot encode JSON' + end + + local frame = template_cfg.frame or self.frame or 'line' + local sent, send_err = client:send(frame == 'line' and encoded .. '\n' or encoded) + if not sent then + client:close() + return nil, send_err + end + + local response, receive_err = client:receive(frame == 'line' and '*l' or '*a') + client:close() + if not response then return nil, receive_err end + + local decoded = json.decode(response) + if decoded ~= nil then return decoded end + return response +end + +function M.new(cfg) + return T.new(cfg) +end + +M.Transport = T return M diff --git a/lua/utcp/transports/text.lua b/lua/utcp/transports/text.lua index d0e007e..70d9344 100644 --- a/lua/utcp/transports/text.lua +++ b/lua/utcp/transports/text.lua @@ -1,8 +1,31 @@ -local json=require('utcp.json'); local M={}; local T={}; T.__index=T -function T.new(cfg) return setmetatable(cfg or {},T) end -function T:call(t,args) - local path=t.path or t.file or self.path; assert(path,'text path is required'); local f,err=io.open(path,'r'); if not f then return nil,err end; local s=f:read('*a'); f:close(); local decoded=json.decode(s); if decoded~=nil then return decoded end; return s +local json = require('utcp.json') + +local M = {} +local T = {} +T.__index = T + +function T.new(cfg) + return setmetatable(cfg or {}, T) end -function M.new(cfg) return T.new(cfg) end -M.Transport=T + +function T:call(template_cfg) + local path = template_cfg.path or template_cfg.file or self.path + assert(path, 'text path is required') + + local file, err = io.open(path, 'r') + if not file then return nil, err end + + local text = file:read('*a') + file:close() + + local decoded = json.decode(text) + if decoded ~= nil then return decoded end + return text +end + +function M.new(cfg) + return T.new(cfg) +end + +M.Transport = T return M diff --git a/lua/utcp/transports/udp.lua b/lua/utcp/transports/udp.lua index 40fa62a..8ded744 100644 --- a/lua/utcp/transports/udp.lua +++ b/lua/utcp/transports/udp.lua @@ -1,9 +1,50 @@ -local json=require('utcp.json'); local M={}; local T={}; T.__index=T -function T.new(cfg) return setmetatable(cfg or {},T) end -function T:call(t,args) - local socket=require('socket'); local u=socket.udp(); u:settimeout(self.timeout or 5); local host=t.host or self.host or '127.0.0.1'; local port=t.port or self.port; local s=json.encode(t.payload or t.body or args); if not s then return nil,'cannot encode JSON' end - local ok,e=u:sendto(s,host,port); if not ok then u:close(); return nil,e end; local out,er=u:receive(); u:close(); if not out then return nil,er end; local decoded=json.decode(out); if decoded~=nil then return decoded end; return out +local json = require('utcp.json') + +local M = {} +local T = {} +T.__index = T + +function T.new(cfg) + return setmetatable(cfg or {}, T) end -function M.new(cfg) return T.new(cfg) end -M.Transport=T + +function T:call(template_cfg, args) + local socket = require('socket') + local client, err = socket.udp() + if not client then return nil, err end + + client:settimeout(self.timeout or 5) + local host = template_cfg.host or self.host or '127.0.0.1' + local port = template_cfg.port or self.port + if not port then + client:close() + return nil, 'udp port is required' + end + + local encoded = json.encode(template_cfg.payload or template_cfg.body or args) + if not encoded then + client:close() + return nil, 'cannot encode JSON' + end + + local sent, send_err = client:sendto(encoded, host, port) + if not sent then + client:close() + return nil, send_err + end + + local response, receive_err = client:receive() + client:close() + if not response then return nil, receive_err end + + local decoded = json.decode(response) + if decoded ~= nil then return decoded end + return response +end + +function M.new(cfg) + return T.new(cfg) +end + +M.Transport = T return M diff --git a/tests/run_http_integration.py b/tests/run_http_integration.py new file mode 100644 index 0000000..bba5057 --- /dev/null +++ b/tests/run_http_integration.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Run the Lua HTTP integration test against the bundled example server.""" + +import os +import shlex +import socket +import subprocess +import sys +import time +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +SERVER = ROOT / "examples" / "servers" / "http_server.py" + + +def unused_local_port() -> int: + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + return listener.getsockname()[1] + + +def server_is_ready(port: int) -> bool: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.1): + return True + except OSError: + return False + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: run_http_integration.py ", file=sys.stderr) + return 2 + + port = unused_local_port() + env = os.environ.copy() + env["UTCP_HTTP_PORT"] = str(port) + server = subprocess.Popen( + [sys.executable, str(SERVER)], + cwd=ROOT, + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + + try: + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if server.poll() is not None: + stderr = server.stderr.read() if server.stderr else "" + print(f"HTTP example server exited early: {stderr}", file=sys.stderr) + return server.returncode or 1 + if server_is_ready(port): + break + time.sleep(0.05) + else: + print("HTTP example server did not start within 5 seconds", file=sys.stderr) + return 1 + + test_env = env.copy() + test_env["UTCP_HTTP_URL"] = f"http://127.0.0.1:{port}/echo" + return subprocess.run( + [*shlex.split(sys.argv[1]), "tests/test_http.lua"], cwd=ROOT, env=test_env + ).returncode + finally: + if server.poll() is None: + server.terminate() + try: + server.wait(timeout=3) + except subprocess.TimeoutExpired: + server.kill() + server.wait() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_cli.lua b/tests/test_cli.lua index 5fd7e85..710ae8c 100644 --- a/tests/test_cli.lua +++ b/tests/test_cli.lua @@ -45,8 +45,8 @@ local nested_result, nested_err = transport:call({ }) assert(nested_err == nil, nested_err) -assert(type(nested_result) == 'string') -assert_contains(nested_result, '"path":"README.md"') +assert(type(nested_result) == 'table') +assert(nested_result.path == 'README.md') -- Existing command + args configuration must continue to work. local legacy = Cli.new({ diff --git a/tests/test_http.lua b/tests/test_http.lua index 647d111..f808db4 100644 --- a/tests/test_http.lua +++ b/tests/test_http.lua @@ -1,8 +1,24 @@ -package.path = './lua/?.lua;./lua/?/init.lua;'..package.path +package.path = './lua/?.lua;./lua/?/init.lua;' .. package.path + local utcp = require('utcp') -local socket = require('socket') -local server = assert(socket.bind('127.0.0.1', 0)); local ip,port=server:getsockname() -print('HTTP integration server expected at '..ip..':'..port) -print('Run against a real HTTP endpoint to exercise lua-socket request semantics.') -server:close() -print('lua-utcp HTTP smoke test: ok') +local url = os.getenv('UTCP_HTTP_URL') or 'http://127.0.0.1:8080/echo' + +local client = utcp.new({}) +client:add_manual({ + tools = { + { + name = 'echo', + tool_call_template = { + call_template_type = 'http', + url = url, + http_method = 'POST', + }, + }, + }, +}) + +local result, err = client:call_tool('echo', {message = 'integration check'}) +assert(result, err) +assert(result.message == 'integration check') + +print('HTTP integration test: ok')