Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ jobs:

- name: Install dependencies
run: |
luarocks install luasocket
luarocks install lua-cjson
luarocks install dkjson
luarocks install luasec
Expand All @@ -38,4 +39,6 @@ jobs:
lua -e "assert(require('cjson.safe'))"

- name: Run tests
run: make test
run: |
make test
make integration
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
7 changes: 4 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
### LuaRocks

```sh
luarocks install lua-utcp-1.3-1.rockspec
luarocks install lua-utcp-1.4-1.rockspec
```

### From Source
Expand Down Expand Up @@ -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))
Expand All @@ -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`

Expand Down Expand Up @@ -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
```
Expand Down
3 changes: 2 additions & 1 deletion examples/servers/http_server.py
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
35 changes: 29 additions & 6 deletions lua/utcp/transports/graphql.lua
Original file line number Diff line number Diff line change
@@ -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
12 changes: 9 additions & 3 deletions lua/utcp/transports/init.lua
Original file line number Diff line number Diff line change
@@ -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'),
}
63 changes: 53 additions & 10 deletions lua/utcp/transports/tcp.lua
Original file line number Diff line number Diff line change
@@ -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
35 changes: 29 additions & 6 deletions lua/utcp/transports/text.lua
Original file line number Diff line number Diff line change
@@ -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
55 changes: 48 additions & 7 deletions lua/utcp/transports/udp.lua
Original file line number Diff line number Diff line change
@@ -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
78 changes: 78 additions & 0 deletions tests/run_http_integration.py
Original file line number Diff line number Diff line change
@@ -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 <lua>", 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())
Loading
Loading