First Version
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Demo MCP server (stdio, JSON-RPC 2.0) exercising MCP elicitation.
|
||||
|
||||
Two tools demonstrate the two card types Skald renders in the Agent Inbox:
|
||||
|
||||
* ``ask_secret`` — elicits a single masked field (``format: password``).
|
||||
Returns only a masked confirmation; the value is held in
|
||||
RAM (this process) and never echoed back to the caller.
|
||||
* ``confirm`` — elicits with an empty schema → a yes/no confirmation.
|
||||
|
||||
Register it from the LLM ("register an MCP server, command python3, args
|
||||
scripts/elicitation_demo_mcp.py") or via the MCP servers UI, then ask the agent
|
||||
to call the tool. The request appears in the Agent Inbox under "Secrets".
|
||||
|
||||
No third-party dependencies: a plain ``readline`` JSON-RPC loop, matching how
|
||||
Skald's stdio client speaks. ``elicitation/create`` is a server→client request;
|
||||
the reply arrives on the same stdin and is matched by its id.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import itertools
|
||||
|
||||
_next_id = itertools.count(1)
|
||||
# Demo-only in-RAM secret cache, mirroring the SSH MCP "prompt" method: keep the
|
||||
# value for the process lifetime, never write it to disk, never return it.
|
||||
_secret_cache: dict[str, str] = {}
|
||||
|
||||
|
||||
def send(obj: dict) -> None:
|
||||
sys.stdout.write(json.dumps(obj) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def readline() -> dict | None:
|
||||
"""Blocking read of one non-empty JSON-RPC line; None on EOF."""
|
||||
while True:
|
||||
line = sys.stdin.readline()
|
||||
if not line:
|
||||
return None
|
||||
line = line.strip()
|
||||
if line:
|
||||
return json.loads(line)
|
||||
|
||||
|
||||
def elicit(message: str, requested_schema: dict) -> dict:
|
||||
"""Send ``elicitation/create`` and block until the matching reply arrives.
|
||||
|
||||
Returns the JSON-RPC ``result`` ({"action": ..., "content": {...}}).
|
||||
"""
|
||||
eid = f"elicit-{next(_next_id)}"
|
||||
send({
|
||||
"jsonrpc": "2.0",
|
||||
"id": eid,
|
||||
"method": "elicitation/create",
|
||||
"params": {"message": message, "requestedSchema": requested_schema},
|
||||
})
|
||||
while True:
|
||||
msg = readline()
|
||||
if msg is None:
|
||||
return {"action": "cancel"}
|
||||
if msg.get("id") == eid:
|
||||
return msg.get("result", {"action": "cancel"})
|
||||
# Any other inbound message mid-wait is ignored for this demo.
|
||||
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "ask_secret",
|
||||
"description": "Ask the user for a secret value (masked) via elicitation.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"label": {"type": "string", "description": "what the secret is for"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "confirm",
|
||||
"description": "Ask the user to confirm an action (yes/no) via elicitation.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"action": {"type": "string", "description": "the action to confirm"}},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def text_result(mid, text: str, is_error: bool = False) -> None:
|
||||
send({"jsonrpc": "2.0", "id": mid, "result": {
|
||||
"content": [{"type": "text", "text": text}], "isError": is_error}})
|
||||
|
||||
|
||||
def handle_call(mid, name: str, args: dict) -> None:
|
||||
if name == "ask_secret":
|
||||
label = args.get("label", "the secret")
|
||||
result = elicit(
|
||||
f"Enter {label}",
|
||||
{"type": "object",
|
||||
"properties": {"secret": {"type": "string", "format": "password",
|
||||
"title": label}},
|
||||
"required": ["secret"]},
|
||||
)
|
||||
action = result.get("action")
|
||||
if action == "accept":
|
||||
value = (result.get("content") or {}).get("secret", "")
|
||||
_secret_cache[label] = value
|
||||
# Never return the secret itself — only proof we received it.
|
||||
text_result(mid, f"OK — received {label} ({len(value)} chars, kept in RAM).")
|
||||
else:
|
||||
text_result(mid, f"Error: {label} required (user {action}).", is_error=True)
|
||||
|
||||
elif name == "confirm":
|
||||
what = args.get("action", "this action")
|
||||
result = elicit(f"Confirm: {what}?", {"type": "object", "properties": {}})
|
||||
action = result.get("action")
|
||||
text_result(mid, f"User {action}ed: {what}." if action == "accept"
|
||||
else f"Not confirmed ({action}): {what}.",
|
||||
is_error=(action != "accept"))
|
||||
else:
|
||||
send({"jsonrpc": "2.0", "id": mid,
|
||||
"error": {"code": -32602, "message": f"unknown tool: {name}"}})
|
||||
|
||||
|
||||
def main() -> None:
|
||||
while True:
|
||||
msg = readline()
|
||||
if msg is None:
|
||||
break
|
||||
mid = msg.get("id")
|
||||
method = msg.get("method")
|
||||
if method == "initialize":
|
||||
send({"jsonrpc": "2.0", "id": mid, "result": {
|
||||
"protocolVersion": "2025-06-18", "capabilities": {},
|
||||
"serverInfo": {"name": "elicitation-demo", "version": "0.1.0"}}})
|
||||
elif method == "notifications/initialized":
|
||||
pass
|
||||
elif method == "tools/list":
|
||||
send({"jsonrpc": "2.0", "id": mid, "result": {"tools": TOOLS}})
|
||||
elif method == "tools/call":
|
||||
params = msg.get("params", {})
|
||||
handle_call(mid, params.get("name", ""), params.get("arguments", {}) or {})
|
||||
elif mid is not None:
|
||||
send({"jsonrpc": "2.0", "id": mid,
|
||||
"error": {"code": -32601, "message": f"method not found: {method}"}})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user