Skip to content
Draft
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
8 changes: 7 additions & 1 deletion Project.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,19 @@ ModelContextProtocol = "c58f755f-f2a7-4f48-bf29-4e9659b78499"
Pkg = "44cfe95a-1eb2-52ea-b672-e2afdf69b78f"
UnicodePlots = "b8865327-cd53-5732-bb35-84acbb429228"

[sources]
# DEV-ONLY pin (do not merge to main): the tool-annotations + structured-output
# features are not yet in a released ModelContextProtocol.jl. When upstream releases
# them, drop this [sources] block and bump the [compat] entry instead.
ModelContextProtocol = {url = "https://github.com/samtalki/ModelContextProtocol.jl", rev = "feat/structured-output"}

[compat]
Aqua = "0.8"
Dates = "1"
JSON3 = "1"
JuliaSyntaxHighlighting = "1"
Malt = "1.4"
ModelContextProtocol = "0.4"
ModelContextProtocol = "0.4, 0.5"
Pkg = "1"
Revise = "3"
Test = "1"
Expand Down
78 changes: 76 additions & 2 deletions src/tools.jl
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# tools.jl - MCP tool definitions

"""
_tool_annotations(; read_only=false, destructive=false, idempotent=false, open_world=false) -> Dict

Build an MCP tool-annotations dict (behavioral hints clients use for trust/UX, e.g.
auto-approval). All four hints are emitted explicitly so clients don't fall back to
spec defaults. See https://modelcontextprotocol.io for semantics.
"""
_tool_annotations(; read_only::Bool=false, destructive::Bool=false,
idempotent::Bool=false, open_world::Bool=false) =
Dict{String,Any}(
"readOnlyHint" => read_only,
"destructiveHint" => destructive,
"idempotentHint" => idempotent,
"openWorldHint" => open_world,
)

"""
_validate_action(params, valid_actions::Vector{String}) -> String

Expand Down Expand Up @@ -41,6 +57,7 @@ function create_eval_tool()
MCPTool(
name = "eval",
title = "Evaluate Julia code",
annotations = _tool_annotations(destructive=true, open_world=true), # runs arbitrary code
description = """
Evaluate Julia code in a persistent Julia REPL session.

Expand Down Expand Up @@ -184,6 +201,7 @@ function create_reset_tool()
MCPTool(
name = "reset",
title = "Reset Julia session",
annotations = _tool_annotations(destructive=true), # kills the worker, drops all state
description = """
Hard reset: Kill the Julia worker process and spawn a fresh one.

Expand Down Expand Up @@ -249,6 +267,20 @@ function create_info_tool()
MCPTool(
name = "info",
title = "Julia session info",
annotations = _tool_annotations(read_only=true, idempotent=true),
output_schema = Dict{String,Any}(
"type" => "object",
"properties" => Dict{String,Any}(
"session" => Dict{String,Any}("type" => "string"),
"julia_version" => Dict{String,Any}("type" => "string"),
"project" => Dict{String,Any}("type" => "string"),
"loaded_modules" => Dict{String,Any}("type" => "integer"),
"revise_loaded" => Dict{String,Any}("type" => "boolean"),
"worker_pid" => Dict{String,Any}("type" => ["integer", "null"]),
"variables" => Dict{String,Any}("type" => "array"),
"notes" => Dict{String,Any}("type" => "array"),
),
),
description = """
Get information about the current Julia session.

Expand Down Expand Up @@ -313,7 +345,22 @@ $vars_str
Loaded Modules: $(info.modules)
Worker pid: $(something(worker_pid(session), "not yet spawned"))
$(notes_str)$(timings_str)"""
TextContent(text = msg)

# Machine-readable mirror of the same info (see the tool's output_schema).
structured = Dict{String,Any}(
"session" => session.name,
"julia_version" => info.version,
"project" => info.project,
"loaded_modules" => info.modules,
"revise_loaded" => session.revise_loaded,
"worker_pid" => worker_pid(session),
"variables" => [Dict{String,Any}("name" => string(v.name), "type" => v.type, "size" => v.size) for v in info.variables],
"notes" => copy(session.worker_notes),
)
CallToolResult(
content = Dict{String,Any}[Dict{String,Any}("type" => "text", "text" => msg)],
structured_content = structured,
)
catch e
e isa InterruptException && rethrow()
e isa OutOfMemoryError && rethrow()
Expand All @@ -332,6 +379,7 @@ function create_pkg_tool()
MCPTool(
name = "pkg",
title = "Manage Julia packages",
annotations = _tool_annotations(destructive=true, open_world=true), # add/rm modify env, download
description = """
Manage Julia packages in the current environment.

Expand Down Expand Up @@ -460,6 +508,7 @@ function create_activate_tool()
MCPTool(
name = "activate",
title = "Activate Julia project",
annotations = _tool_annotations(idempotent=true), # switches env; re-activating is a no-op
description = """
Activate a Julia project or environment.

Expand Down Expand Up @@ -522,6 +571,7 @@ function create_log_viewer_tool()
MCPTool(
name = "log_viewer",
title = "Julia log viewer",
annotations = _tool_annotations(), # toggles a viewer; no session state change
description = """
Open a separate terminal window showing Julia REPL output in real-time.

Expand Down Expand Up @@ -582,6 +632,14 @@ function create_session_tool()
MCPTool(
name = "session",
title = "Manage Julia sessions",
annotations = _tool_annotations(destructive=true), # destroy kills a worker
output_schema = Dict{String,Any}( # describes the list action's structured result
"type" => "object",
"properties" => Dict{String,Any}(
"sessions" => Dict{String,Any}("type" => "array"),
"current" => Dict{String,Any}("type" => ["string", "null"]),
),
),
description = """
Manage multiple Julia REPL sessions.

Expand Down Expand Up @@ -653,7 +711,22 @@ Examples:
age_min = round(s.age_seconds / 60; digits=1)
push!(lines, "$marker $(s.name) — $worker, $project, $revise ($(age_min)min)")
end
TextContent(text = join(lines, "\n"))
# Machine-readable mirror (see the tool's output_schema).
structured = Dict{String,Any}(
"current" => SESSIONS.current,
"sessions" => [Dict{String,Any}(
"name" => s.name,
"worker_pid" => s.worker_pid,
"project" => s.project,
"revise_loaded" => s.revise,
"is_current" => s.is_current,
"age_seconds" => round(s.age_seconds; digits=1),
) for s in sessions],
)
CallToolResult(
content = Dict{String,Any}[Dict{String,Any}("type" => "text", "text" => join(lines, "\n"))],
structured_content = structured,
)

elseif action_lower == "destroy"
destroy_session!(name)
Expand Down Expand Up @@ -712,6 +785,7 @@ function create_revise_tool()
MCPTool(
name = "revise",
title = "Hot-reload (Revise.jl)",
annotations = _tool_annotations(idempotent=true), # reloads tracked changes
description = """
Hot-reload Julia code changes using Revise.jl — no session restart needed.

Expand Down
19 changes: 18 additions & 1 deletion test/test_mcp_protocol.jl
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,16 @@ end
tool_names = Set([t["name"] for t in tools])
expected = Set(["eval", "reset", "info", "pkg", "activate", "log_viewer", "session", "revise"])
@test tool_names == expected
# Every tool carries a human-friendly title (B3)
# Every tool carries a human-friendly title
@test all(haskey(t, "title") && !isempty(t["title"]) for t in tools)
# Tool annotations (needs the forked ModelContextProtocol.jl)
byname = Dict(t["name"] => t for t in tools)
@test byname["info"]["annotations"]["readOnlyHint"] == true
@test byname["reset"]["annotations"]["destructiveHint"] == true
@test byname["eval"]["annotations"]["openWorldHint"] == true
# Structured-output schema on the structured tools
@test haskey(byname["info"], "outputSchema")
@test haskey(byname["session"], "outputSchema")
end

# --- eval tool ---
Expand Down Expand Up @@ -239,6 +247,11 @@ end
text = get_tool_text(resp)
@test occursin("Julia Version", text)
@test occursin("default", text) # session name
# structured mirror (needs the forked ModelContextProtocol.jl)
sc = resp["result"]["structuredContent"]
@test haskey(sc, "julia_version")
@test haskey(sc, "worker_pid")
@test sc["session"] == "default"
end

# --- session tool ---
Expand All @@ -254,6 +267,10 @@ end
text = get_tool_text(resp)
@test occursin("test-s1", text)
@test occursin("default", text)
# structured mirror (needs the forked ModelContextProtocol.jl)
sc = resp["result"]["structuredContent"]
@test !isempty(sc["sessions"])
@test any(s -> s["name"] == "test-s1", sc["sessions"])
end

@testset "session - isolation" begin
Expand Down
Loading