mirror of
https://github.com/MCCTeam/Minecraft-Console-Client
synced 2026-08-15 13:04:36 +00:00
feat: Implemented Model Context Protocol (MCP) into MCC
This commit is contained in:
commit
d6b3dec8ad
57 changed files with 12885 additions and 26 deletions
386
.skills/general-prompt-engineer/SKILL.md
Normal file
386
.skills/general-prompt-engineer/SKILL.md
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
---
|
||||
name: general-prompt-engineer
|
||||
description: create, repair, compress, and optimize prompts, system messages, tool instructions, schemas, and eval rubrics for general tasks across writing, research, coding, analysis, planning, tutoring, automation, and agent workflows. use when the user wants a new prompt, wants an existing prompt improved, wants prompt failures debugged, or needs better structure for grounding, tool use, output format, or reliability.
|
||||
---
|
||||
|
||||
# Prompt Engineer
|
||||
|
||||
Build prompts that are clear, compact, reliable, and easy to evaluate. Optimize for modern frontier models, but keep prompts portable across model families unless the user explicitly asks for model-specific tuning.
|
||||
|
||||
## Default workflow
|
||||
|
||||
1. Diagnose the request.
|
||||
2. Decide whether prompt changes are the real fix.
|
||||
3. Gather only missing information.
|
||||
4. Choose the lightest structure that will work.
|
||||
5. Draft the prompt.
|
||||
6. Stress-test it mentally.
|
||||
7. Deliver only what the user asked for.
|
||||
|
||||
### 1) Diagnose the request
|
||||
|
||||
Extract:
|
||||
- objective
|
||||
- target actor or model
|
||||
- required output
|
||||
- constraints and non-goals
|
||||
- source material and freshness needs
|
||||
- tool or schema needs
|
||||
- likely failure modes
|
||||
- interaction mode: interactive, one-shot, or automated
|
||||
|
||||
Before rewriting, check whether the problem is actually caused by:
|
||||
- the wrong model
|
||||
- weak or excessive tool design
|
||||
- missing retrieval or grounding
|
||||
- missing schema or validation
|
||||
- missing evals
|
||||
- an overcomplicated workflow
|
||||
|
||||
If prompt changes are not the main lever, say so and adjust the solution.
|
||||
|
||||
### 2) Gather only missing information
|
||||
|
||||
Ask targeted questions only when the answer would materially change the prompt or output.
|
||||
Usually clarify:
|
||||
- output shape
|
||||
- hard constraints
|
||||
- source of truth
|
||||
- allowed tools
|
||||
- audience or tone, if important
|
||||
- success criteria or examples, if available
|
||||
|
||||
Do not run a long interview. If the user likely wants speed, state a small set of assumptions and proceed.
|
||||
|
||||
### 3) Choose the lightest structure that will work
|
||||
|
||||
Use this ladder:
|
||||
- Plain prompt: simple tasks with clear outputs
|
||||
- Labeled sections: tasks with multiple constraints or source material
|
||||
- Schema-based prompt: machine-validated output or tool calls
|
||||
- Staged workflow: multi-step transformations, verification, or research synthesis
|
||||
- Agent prompt: only when autonomy, tools, or long-horizon execution are required
|
||||
- Multi-agent design: only if evals or clear role separation justify it
|
||||
|
||||
Do not force a giant template onto a small task.
|
||||
|
||||
## Core rules
|
||||
|
||||
### Instruction clarity
|
||||
|
||||
- Put the main task and required output near the top.
|
||||
- Use direct verbs.
|
||||
- Say what to do, not only what to avoid.
|
||||
- Make constraints measurable when possible.
|
||||
- Name out-of-bounds behavior explicitly.
|
||||
- Do not make the model infer facts or parameters you already know.
|
||||
- Remove contradictions before adding more guidance.
|
||||
|
||||
### Context loading discipline
|
||||
|
||||
- Include only context that helps the task.
|
||||
- Separate stable instructions from variable task data.
|
||||
- Label documents, examples, and reference material clearly.
|
||||
- For source-heavy prompts, keep the operative question easy to find.
|
||||
- For long-document work, anchor important claims to quoted text, citations, or section references when precision matters.
|
||||
- For very long or noisy documents, consider an evidence-first step: extract the relevant passages first, then synthesize.
|
||||
- Remove repeated policies, repeated facts, and ornamental prose.
|
||||
- When a task is dominated by long source material, use strong delimiters and make the final requested action unmistakable.
|
||||
|
||||
### Reasoning control
|
||||
|
||||
- Do not force visible chain-of-thought by default.
|
||||
- For reasoning-first models, prefer concise high-level guidance such as "reason carefully", "check assumptions", or "verify before answering" rather than "think step by step".
|
||||
- Ask for visible reasoning only when it serves the task: tutoring, auditability, debugging, derivations, safety review, or explicit rationale requests.
|
||||
- If one prompt is trying to do too much, split it into stages instead of demanding a long visible reasoning trace.
|
||||
- If the target model supports extended or internal thinking, rely on that before adding verbose reasoning rituals.
|
||||
|
||||
### Examples
|
||||
|
||||
- Try zero-shot first for strong modern models.
|
||||
- Add examples only when they reduce ambiguity, enforce style, or demonstrate hard edge cases.
|
||||
- Keep examples high-quality, diverse, and tightly aligned with the instructions.
|
||||
- Do not include many examples that teach accidental patterns or waste context.
|
||||
|
||||
### Structure and output design
|
||||
|
||||
- Use plain markdown or labeled sections for most prompts.
|
||||
- Use XML tags or equivalent delimiters when instructions, context, examples, and documents might otherwise get mixed together.
|
||||
- Use schemas when the output must be machine-checked.
|
||||
- For external actions, use tool or function calling; for user-facing structured data, use structured response formats.
|
||||
- Design schemas so valid failure states, uncertainty, abstention, or partial completion can be represented when needed.
|
||||
- Do not over-constrain fields beyond what downstream systems actually require.
|
||||
- Include fallback behavior for incompatible input, missing fields, uncertainty, or refusal states.
|
||||
- Treat format validation and content validation as separate problems.
|
||||
|
||||
### Tool-use guidance
|
||||
|
||||
- Add tools only when the task truly needs external information, computation, or actions.
|
||||
- Keep the tool set small, distinct, and easy to choose between.
|
||||
- State when each tool should be used and when it should not be used.
|
||||
- Prefer tools that return high-signal results over bulky raw dumps.
|
||||
- Combine tightly coupled actions when that reduces tool-selection ambiguity.
|
||||
- For complex tools, clear descriptions and valid examples matter more than more tools.
|
||||
|
||||
### Grounding and hallucination reduction
|
||||
|
||||
- Give the model permission to say "I don't know" or "not enough information".
|
||||
- Name the allowed sources of truth.
|
||||
- For document-grounded tasks, require evidence before synthesis when precision matters.
|
||||
- For fresh, unstable, or high-stakes facts, require browsing or verification.
|
||||
- Ask the model to separate facts, inferences, and recommendations when confusion is likely.
|
||||
- In high-stakes domains, unsupported claims should be withheld, not guessed.
|
||||
|
||||
### Ambiguity handling
|
||||
|
||||
- If ambiguity is blocking and the setting is interactive, ask concise high-leverage questions.
|
||||
- If ambiguity is non-blocking or interaction is costly, state the best assumption and proceed.
|
||||
- Avoid clarifying questions that do not materially change the answer.
|
||||
- In one-shot or automated settings, prefer explicit assumptions over stalled execution.
|
||||
|
||||
### Verbosity control
|
||||
|
||||
- Set a default brevity level when length matters.
|
||||
- Constrain section count, sentence count, or bullet count when needed.
|
||||
- Ask for direct answers first, then supporting detail if useful.
|
||||
- Do not require long preambles, summaries, or checklists unless they clearly help.
|
||||
|
||||
### Modularity and portability
|
||||
|
||||
- Keep prompt blocks reusable: role, objective, context, tools, output, quality bar.
|
||||
- Separate required behavior from optional preferences.
|
||||
- Avoid vendor-specific magic phrases unless the user wants model-specific tuning.
|
||||
- If the prompt is model-specific, label which parts are portable and which parts are tuned.
|
||||
|
||||
## Model-family adjustments
|
||||
|
||||
Use this section only when the target model family is known.
|
||||
|
||||
### GPT-5.x and similar reasoning-first models
|
||||
|
||||
- Keep prompts simple and direct.
|
||||
- Prefer high-level reasoning guidance over narrated reasoning instructions.
|
||||
- Use delimiters for clarity.
|
||||
- Start zero-shot, then add examples only if needed.
|
||||
- Be explicit about output shape, scope, and verbosity.
|
||||
|
||||
### Claude 4.x, Opus-style models, and extended-thinking modes
|
||||
|
||||
- XML-style structure can work especially well for separating instructions, context, examples, and documents.
|
||||
- Prompt chaining can outperform one giant prompt on multi-step transformations.
|
||||
- Well-chosen examples can help with format fidelity and edge cases.
|
||||
- If extended thinking is available, start with broad reasoning instructions before prescribing a detailed step list.
|
||||
- For long-context analysis, labeled documents and evidence grounding are especially important.
|
||||
|
||||
### API and production settings
|
||||
|
||||
- Prefer native schema enforcement, tool calling, prompt versioning, and evals over prompt-only fixes.
|
||||
- Pin model versions when behavior stability matters.
|
||||
- Re-run evals after each meaningful prompt change.
|
||||
|
||||
## Prompt construction pattern
|
||||
|
||||
Use only the blocks that earn their token cost.
|
||||
|
||||
Minimal pattern:
|
||||
|
||||
```text
|
||||
Task:
|
||||
Constraints:
|
||||
Output:
|
||||
```
|
||||
|
||||
Structured pattern:
|
||||
|
||||
```xml
|
||||
<role>...</role>
|
||||
<objective>...</objective>
|
||||
<context>...</context>
|
||||
<constraints>...</constraints>
|
||||
<tools>...</tools>
|
||||
<output_format>...</output_format>
|
||||
<quality_bar>...</quality_bar>
|
||||
```
|
||||
|
||||
Optional blocks:
|
||||
- `<examples>`
|
||||
- `<source_material>`
|
||||
- `<evaluation_criteria>`
|
||||
- `<fallback_behavior>`
|
||||
|
||||
Use a role only when it meaningfully sharpens expertise, tone, or decision criteria. Avoid generic filler roles.
|
||||
|
||||
## Rewrite policy for existing prompts
|
||||
|
||||
When the user provides a prompt to improve:
|
||||
1. Preserve what already works.
|
||||
2. Identify contradictions, redundancy, vagueness, missing constraints, and wasted tokens.
|
||||
3. Make surgical edits first.
|
||||
4. Rewrite from scratch only if the prompt architecture is fundamentally wrong.
|
||||
5. Match the user's requested output:
|
||||
- edited version only
|
||||
- clean rebuild only
|
||||
- both, if useful and requested
|
||||
|
||||
## Special-case guidance
|
||||
|
||||
### System and developer prompts
|
||||
|
||||
- Keep stable behavior here and move per-request data to the task or user layer.
|
||||
- Put precedence, tool boundaries, non-goals, and refusal or escalation rules in the highest-priority layer.
|
||||
- Do not bury critical rules inside long policy prose.
|
||||
|
||||
### Research prompts
|
||||
|
||||
Specify:
|
||||
- freshness requirements
|
||||
- preferred source types
|
||||
- citation behavior
|
||||
- contradiction handling
|
||||
- whether to ask questions or cover likely interpretations
|
||||
- how facts, inferences, and recommendations should be separated
|
||||
|
||||
### Writing prompts
|
||||
|
||||
Specify:
|
||||
- audience
|
||||
- intent
|
||||
- tone
|
||||
- length
|
||||
- must-include points
|
||||
- style examples only if style fidelity matters
|
||||
|
||||
### Coding prompts
|
||||
|
||||
Specify:
|
||||
- environment and versions
|
||||
- boundaries and non-goals
|
||||
- files, interfaces, or contracts that matter
|
||||
- acceptance tests
|
||||
- minimal-change versus refactor expectations
|
||||
|
||||
### Summarization and extraction prompts
|
||||
|
||||
Specify:
|
||||
- whether faithfulness, compression, or completeness is the priority
|
||||
- the exact output schema
|
||||
- how evidence should be anchored for sensitive claims
|
||||
|
||||
### Translation and transformation prompts
|
||||
|
||||
Specify:
|
||||
- source language and target language, if known
|
||||
- fidelity versus naturalness
|
||||
- terminology that must stay fixed
|
||||
- formatting or markup preservation rules
|
||||
|
||||
### Tutoring prompts
|
||||
|
||||
Specify:
|
||||
- learner level
|
||||
- whether to give the answer immediately or guide toward it
|
||||
- explanation depth
|
||||
- how to check understanding
|
||||
- whether to show full derivations, hints, or worked examples
|
||||
|
||||
### Agent and workflow prompts
|
||||
|
||||
Specify:
|
||||
- objective and success condition
|
||||
- allowed tools and forbidden actions
|
||||
- when to plan versus when to act
|
||||
- stop conditions and max retries
|
||||
- checkpoint, handoff, or log format
|
||||
- memory rules: what to preserve versus discard
|
||||
- fallback or escalation path
|
||||
|
||||
Use multi-agent designs only when roles are truly distinct and the extra coordination cost is justified.
|
||||
|
||||
### Safety-sensitive prompts
|
||||
|
||||
Require:
|
||||
- supported claims
|
||||
- explicit uncertainty
|
||||
- refusal or escalation behavior where appropriate
|
||||
- no guessing under pressure
|
||||
|
||||
## Stress-test before delivering
|
||||
|
||||
Mentally test the prompt against:
|
||||
- a normal case
|
||||
- a minimal-input case
|
||||
- an edge case
|
||||
- an ambiguous case
|
||||
- a formatting case
|
||||
- a hallucination-prone case
|
||||
|
||||
For agent or workflow prompts, also test:
|
||||
- wrong-tool temptation
|
||||
- stale-data temptation
|
||||
- scope creep
|
||||
- over-verbosity
|
||||
- fallback behavior
|
||||
|
||||
If the prompt fails any test, tighten or simplify it.
|
||||
|
||||
## Evaluation method
|
||||
|
||||
When the user wants reliability, add or suggest a lightweight eval plan:
|
||||
1. Define success criteria.
|
||||
2. Build a test set from real cases plus edge and adversarial cases.
|
||||
3. Prefer automated grading when possible.
|
||||
4. Calibrate automated or model-based judges against a smaller human-reviewed set when stakes are meaningful.
|
||||
5. Use pairwise comparison, classification, pass-fail, or rubric-based scoring instead of only open-ended judgment.
|
||||
6. Track regressions after each prompt change.
|
||||
7. Start simple. Add workflows or multi-agent designs only if evals justify them.
|
||||
|
||||
Good eval sets usually include:
|
||||
- common real tasks
|
||||
- boundary cases
|
||||
- malformed inputs
|
||||
- conflicting instructions
|
||||
- long-context cases
|
||||
- tool-misuse temptations
|
||||
- safety-sensitive cases
|
||||
- multilingual or format-variant inputs, if relevant
|
||||
|
||||
## Deliverables
|
||||
|
||||
Return only what the user asked for. By default:
|
||||
1. the final prompt
|
||||
2. brief usage notes
|
||||
3. stated assumptions, if any
|
||||
4. optional variants only when clearly useful:
|
||||
- minimal
|
||||
- robust
|
||||
- model-specific
|
||||
- api message split
|
||||
|
||||
If the user asks for one prompt only, do not add extra frameworks or commentary.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- forcing chain-of-thought everywhere
|
||||
- confusing verbosity with quality
|
||||
- piling on redundant rules
|
||||
- using brittle giant templates for small tasks
|
||||
- requiring tools without a real need
|
||||
- exposing unnecessary internal process in user-facing outputs
|
||||
- adding examples that conflict with the instructions
|
||||
- asking many clarifying questions when a sane assumption would do
|
||||
- treating a model, retrieval, or tool problem as only a prompt problem
|
||||
- building multi-agent systems before a simpler design has been evaluated
|
||||
- vague quality bars like "be excellent" without measurable criteria
|
||||
|
||||
## Final quality bar
|
||||
|
||||
A prompt is ready when it is:
|
||||
- clear about the task
|
||||
- explicit about success criteria
|
||||
- free of contradictions
|
||||
- no more verbose than necessary
|
||||
- grounded in the right sources
|
||||
- structured enough for the task, but not heavier than needed
|
||||
- resilient to likely ambiguity
|
||||
- matched to the target model and interaction mode
|
||||
- easy to maintain, test, and adapt
|
||||
14
DebugTools/MccMcpSampleClient/MccMcpSampleClient.csproj
Normal file
14
DebugTools/MccMcpSampleClient/MccMcpSampleClient.csproj
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ModelContextProtocol" Version="1.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
773
DebugTools/MccMcpSampleClient/Program.cs
Normal file
773
DebugTools/MccMcpSampleClient/Program.cs
Normal file
|
|
@ -0,0 +1,773 @@
|
|||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
string endpoint = Environment.GetEnvironmentVariable("MCC_MCP_ENDPOINT") ?? "http://127.0.0.1:33333/mcp";
|
||||
bool useStdio = string.Equals(Environment.GetEnvironmentVariable("MCC_MCP_USE_STDIO"), "1", StringComparison.Ordinal);
|
||||
string? mcpAuthToken = Environment.GetEnvironmentVariable("MCC_MCP_AUTH_TOKEN");
|
||||
string repoRoot = FindRepoRoot();
|
||||
string rconScript = Path.Combine(repoRoot, "tools", "mc-rcon.sh");
|
||||
string rconPort = Environment.GetEnvironmentVariable("MCC_RCON_PORT") ?? "25575";
|
||||
string rconPassword = Environment.GetEnvironmentVariable("MCC_RCON_PASSWORD") ?? "test123";
|
||||
bool skipSetup = string.Equals(Environment.GetEnvironmentVariable("MCC_MCP_SKIP_SETUP"), "1", StringComparison.Ordinal);
|
||||
bool runLocalSetup = !useStdio && !skipSetup && IsLocalEndpoint(endpoint) && File.Exists(rconScript);
|
||||
var executed = new List<object>();
|
||||
var checks = new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
await using McpClient client = useStdio
|
||||
? await McpClient.CreateAsync(new StdioClientTransport(CreateStdioOptions()))
|
||||
: await McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions
|
||||
{
|
||||
Endpoint = new Uri(endpoint),
|
||||
TransportMode = HttpTransportMode.AutoDetect,
|
||||
AdditionalHeaders = string.IsNullOrWhiteSpace(mcpAuthToken)
|
||||
? null
|
||||
: new Dictionary<string, string> { ["Authorization"] = $"Bearer {mcpAuthToken}" }
|
||||
}));
|
||||
|
||||
ToolEnvelope initialWorldState = await CallSuccessAsync(client, executed, "mcc_world_state");
|
||||
JsonElement initialWorldData = RequireData(initialWorldState);
|
||||
string botName = ReadString(initialWorldData, "username") ?? "CursorBot";
|
||||
Coordinate initialLocation = ReadCoordinate(initialWorldData, "location");
|
||||
|
||||
long setupBaseline = 0;
|
||||
if (runLocalSetup)
|
||||
{
|
||||
ToolEnvelope baselineEvents = await CallSuccessAsync(client, executed, "mcc_recent_events", new Dictionary<string, object?>
|
||||
{
|
||||
["afterId"] = 0L,
|
||||
["maxCount"] = 1
|
||||
});
|
||||
setupBaseline = ReadInt64(RequireData(baselineEvents), "latestId");
|
||||
|
||||
await PrepareWorldAsync(rconScript, rconPort, rconPassword, botName, initialLocation);
|
||||
await Task.Delay(1500);
|
||||
}
|
||||
|
||||
ToolEnvelope worldState = await WaitForPredicateAsync(
|
||||
client,
|
||||
executed,
|
||||
"mcc_world_state",
|
||||
null,
|
||||
envelope =>
|
||||
{
|
||||
if (!envelope.Success || envelope.Data is not JsonElement data)
|
||||
return false;
|
||||
|
||||
return data.TryGetProperty("loadedChunkCount", out JsonElement loaded)
|
||||
&& loaded.TryGetInt32(out int loadedChunkCount)
|
||||
&& loadedChunkCount >= 0
|
||||
&& HasNonNullProperty(data, "worldAge")
|
||||
&& HasNonNullProperty(data, "timeOfDay");
|
||||
},
|
||||
"mcc_world_state never reported chunk/time state.");
|
||||
|
||||
JsonElement worldData = RequireData(worldState);
|
||||
Coordinate worldLocation = ReadCoordinate(worldData, "location");
|
||||
string dimension = RequireString(worldData, "dimension");
|
||||
int loadedChunkCount = ReadInt32(worldData, "loadedChunkCount");
|
||||
int pendingChunkCount = ReadInt32(worldData, "pendingChunkCount");
|
||||
int totalChunkCount = ReadInt32(worldData, "totalChunkCount");
|
||||
double loadRatio = ReadDouble(worldData, "loadRatio");
|
||||
_ = RequireString(worldData, "host");
|
||||
_ = ReadInt32(worldData, "port");
|
||||
_ = RequireString(worldData, "username");
|
||||
_ = ReadInt32(worldData, "protocol");
|
||||
_ = ReadDouble(worldData, "tps");
|
||||
Ensure(!string.IsNullOrWhiteSpace(dimension), "mcc_world_state returned an empty dimension.");
|
||||
Ensure(loadedChunkCount + pendingChunkCount == totalChunkCount, "mcc_world_state chunk counters are inconsistent.");
|
||||
Ensure(loadRatio is >= 0 and <= 1, "mcc_world_state loadRatio is out of range.");
|
||||
Ensure(HasNonNullProperty(worldData, "worldAge"), "mcc_world_state.worldAge is null.");
|
||||
Ensure(HasNonNullProperty(worldData, "timeOfDay"), "mcc_world_state.timeOfDay is null.");
|
||||
if (runLocalSetup || useStdio)
|
||||
{
|
||||
Ensure(HasNonNullProperty(worldData, "rainLevel"), "mcc_world_state.rainLevel is null after setup.");
|
||||
Ensure(HasNonNullProperty(worldData, "thunderLevel"), "mcc_world_state.thunderLevel is null after setup.");
|
||||
}
|
||||
checks.Add("mcc_world_state");
|
||||
|
||||
ToolEnvelope chunkStatus = await CallSuccessAsync(client, executed, "mcc_chunk_status");
|
||||
JsonElement chunkData = RequireData(chunkStatus);
|
||||
JsonElement chunk = RequireProperty(chunkData, "chunk");
|
||||
_ = ReadInt32(chunk, "x");
|
||||
_ = ReadInt32(chunk, "z");
|
||||
Ensure(ReadBoolean(chunkData, "loaded"), "mcc_chunk_status reported the current chunk as unloaded.");
|
||||
Ensure(ReadInt32(chunkData, "loadedChunkCount") + ReadInt32(chunkData, "pendingChunkCount") == ReadInt32(chunkData, "totalChunkCount"),
|
||||
"mcc_chunk_status chunk counters are inconsistent.");
|
||||
checks.Add("mcc_chunk_status");
|
||||
|
||||
await CallSuccessAsync(client, executed, "mcc_look_direction", new Dictionary<string, object?> { ["direction"] = "Down" });
|
||||
ToolEnvelope raycast = await CallSuccessAsync(client, executed, "mcc_raycast_block", new Dictionary<string, object?>
|
||||
{
|
||||
["maxDistance"] = 8.0,
|
||||
["includeNeighbors"] = true
|
||||
});
|
||||
JsonElement raycastData = RequireData(raycast);
|
||||
Ensure(ReadBoolean(raycastData, "hit"), "mcc_raycast_block did not report a hit after looking down.");
|
||||
JsonElement raycastBlock = RequireProperty(raycastData, "block");
|
||||
Ensure(!string.Equals(RequireString(raycastBlock, "material"), "Air", StringComparison.OrdinalIgnoreCase),
|
||||
"mcc_raycast_block hit Air instead of a solid block.");
|
||||
Ensure(RequireProperty(raycastData, "neighbors").ValueKind == JsonValueKind.Object,
|
||||
"mcc_raycast_block did not include neighbors when requested.");
|
||||
checks.Add("mcc_raycast_block");
|
||||
|
||||
ToolEnvelope pathPreview = await CallSuccessAsync(client, executed, "mcc_path_preview", new Dictionary<string, object?>
|
||||
{
|
||||
["x"] = Math.Floor(worldLocation.X) + 2,
|
||||
["y"] = worldLocation.Y,
|
||||
["z"] = Math.Floor(worldLocation.Z),
|
||||
["allowUnsafe"] = false,
|
||||
["timeoutMs"] = 2000,
|
||||
["maxWaypoints"] = 32
|
||||
});
|
||||
JsonElement pathData = RequireData(pathPreview);
|
||||
Ensure(ReadBoolean(pathData, "pathFound"), "mcc_path_preview did not find a path to a nearby target.");
|
||||
Ensure(RequireProperty(pathData, "waypoints").GetArrayLength() > 0, "mcc_path_preview returned no waypoints.");
|
||||
checks.Add("mcc_path_preview");
|
||||
|
||||
ToolEnvelope stoneSearch = await WaitForPredicateAsync(
|
||||
client,
|
||||
executed,
|
||||
"mcc_inventory_search",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["query"] = "Stone",
|
||||
["maxCount"] = 20,
|
||||
["exactMatch"] = true,
|
||||
["includeContainers"] = false
|
||||
},
|
||||
envelope => envelope.Success && envelope.Data is JsonElement data && ReadInt32(data, "count") > 0,
|
||||
"mcc_inventory_search never found Stone in the player inventory.");
|
||||
Ensure(ContainsItemType(RequireData(stoneSearch), "Stone"), "mcc_inventory_search results did not include Stone.");
|
||||
|
||||
ToolEnvelope swordSearch = await WaitForPredicateAsync(
|
||||
client,
|
||||
executed,
|
||||
"mcc_inventory_search",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["query"] = "DiamondSword",
|
||||
["maxCount"] = 20,
|
||||
["exactMatch"] = true,
|
||||
["includeContainers"] = false
|
||||
},
|
||||
envelope => envelope.Success && envelope.Data is JsonElement data && ReadInt32(data, "count") > 0,
|
||||
"mcc_inventory_search never found DiamondSword in the player inventory.");
|
||||
Ensure(ContainsItemType(RequireData(swordSearch), "DiamondSword"), "mcc_inventory_search results did not include DiamondSword.");
|
||||
checks.Add("mcc_inventory_search");
|
||||
|
||||
ToolEnvelope selectItem = await CallSuccessAsync(client, executed, "mcc_select_item", new Dictionary<string, object?>
|
||||
{
|
||||
["itemType"] = "DiamondSword",
|
||||
["preferLowestSlot"] = true
|
||||
});
|
||||
JsonElement selectData = RequireData(selectItem);
|
||||
int selectedSlot = ReadInt32(selectData, "selectedSlot");
|
||||
|
||||
ToolEnvelope playerStats = await CallSuccessAsync(client, executed, "mcc_player_stats");
|
||||
JsonElement playerStatsData = RequireData(playerStats);
|
||||
Ensure(ReadInt32(playerStatsData, "currentSlot") == selectedSlot, "mcc_select_item did not update mcc_player_stats.currentSlot.");
|
||||
_ = ReadInt32(playerStatsData, "playerEntityId");
|
||||
_ = ReadInt32(playerStatsData, "level");
|
||||
_ = ReadInt32(playerStatsData, "totalExperience");
|
||||
_ = ReadCoordinate(playerStatsData, "location");
|
||||
checks.Add("mcc_select_item");
|
||||
checks.Add("mcc_player_stats");
|
||||
|
||||
ToolEnvelope playersDetailed = await CallSuccessAsync(client, executed, "mcc_players_detailed", new Dictionary<string, object?>
|
||||
{
|
||||
["includeSelf"] = true,
|
||||
["includeCoordinates"] = true
|
||||
});
|
||||
JsonElement playersData = RequireData(playersDetailed);
|
||||
JsonElement selfPlayer = FindPlayer(RequireProperty(playersData, "players"), botName);
|
||||
_ = RequireString(selfPlayer, "uuid");
|
||||
_ = ReadInt32(selfPlayer, "ping");
|
||||
_ = ReadInt32(selfPlayer, "entityId");
|
||||
_ = ReadDouble(selfPlayer, "x");
|
||||
_ = ReadDouble(selfPlayer, "y");
|
||||
_ = ReadDouble(selfPlayer, "z");
|
||||
checks.Add("mcc_players_detailed");
|
||||
|
||||
ToolEnvelope statusEffects = await CallSuccessAsync(client, executed, "mcc_status_effects");
|
||||
Ensure(RequireProperty(RequireData(statusEffects), "effects").ValueKind == JsonValueKind.Array,
|
||||
"mcc_status_effects.effects is not an array.");
|
||||
checks.Add("mcc_status_effects");
|
||||
|
||||
ToolEnvelope animation = await CallSuccessAsync(client, executed, "mcc_animation", new Dictionary<string, object?>
|
||||
{
|
||||
["hand"] = "MainHand"
|
||||
});
|
||||
Ensure(ReadBoolean(RequireData(animation), "success"), "mcc_animation did not report success.");
|
||||
|
||||
ToolEnvelope sneakOn = await CallSuccessAsync(client, executed, "mcc_toggle_sneak", new Dictionary<string, object?> { ["enabled"] = true });
|
||||
Ensure(ReadBoolean(RequireData(sneakOn), "enabled"), "mcc_toggle_sneak(true) did not report enabled=true.");
|
||||
|
||||
ToolEnvelope sprintOn = await CallSuccessAsync(client, executed, "mcc_toggle_sprint", new Dictionary<string, object?> { ["enabled"] = true });
|
||||
Ensure(ReadBoolean(RequireData(sprintOn), "enabled"), "mcc_toggle_sprint(true) did not report enabled=true.");
|
||||
|
||||
await CallSuccessAsync(client, executed, "mcc_look_angles", new Dictionary<string, object?>
|
||||
{
|
||||
["yaw"] = 45.0f,
|
||||
["pitch"] = -15.0f
|
||||
});
|
||||
ToolEnvelope updatedStats = await CallSuccessAsync(client, executed, "mcc_player_stats");
|
||||
JsonElement updatedStatsData = RequireData(updatedStats);
|
||||
Ensure(Math.Abs(ReadDouble(updatedStatsData, "yaw") - 45.0) < 0.01, "mcc_look_angles did not update yaw.");
|
||||
Ensure(Math.Abs(ReadDouble(updatedStatsData, "pitch") - (-15.0)) < 0.01, "mcc_look_angles did not update pitch.");
|
||||
checks.Add("mcc_animation");
|
||||
checks.Add("mcc_toggle_sneak");
|
||||
checks.Add("mcc_toggle_sprint");
|
||||
checks.Add("mcc_look_direction");
|
||||
checks.Add("mcc_look_angles");
|
||||
|
||||
ToolEnvelope nearestEntity = await WaitForPredicateAsync(
|
||||
client,
|
||||
executed,
|
||||
"mcc_entity_nearest",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["typeFilter"] = "ArmorStand",
|
||||
["radius"] = 16.0,
|
||||
["includePlayers"] = false
|
||||
},
|
||||
envelope => envelope.Success,
|
||||
"mcc_entity_nearest never found a nearby ArmorStand.");
|
||||
JsonElement nearestData = RequireData(nearestEntity);
|
||||
int entityId = ReadInt32(nearestData, "id");
|
||||
Ensure(string.Equals(RequireString(nearestData, "type"), "ArmorStand", StringComparison.OrdinalIgnoreCase),
|
||||
"mcc_entity_nearest did not return an ArmorStand.");
|
||||
|
||||
ToolEnvelope attackEntity = await CallSuccessAsync(client, executed, "mcc_entity_attack", new Dictionary<string, object?>
|
||||
{
|
||||
["entityId"] = entityId
|
||||
});
|
||||
Ensure(ReadBoolean(RequireData(attackEntity), "success"), "mcc_entity_attack did not report success.");
|
||||
checks.Add("mcc_entity_nearest");
|
||||
checks.Add("mcc_entity_attack");
|
||||
|
||||
long recentSetupAfterId = runLocalSetup ? setupBaseline : 0;
|
||||
if (runLocalSetup || useStdio)
|
||||
{
|
||||
ToolEnvelope setupEvents = await WaitForRecentEventTypesAsync(
|
||||
client,
|
||||
executed,
|
||||
recentSetupAfterId,
|
||||
"weather_rain",
|
||||
"title",
|
||||
"actionbar");
|
||||
JsonElement setupEventsData = RequireData(setupEvents);
|
||||
Ensure(GetEventTypes(setupEventsData).Contains("weather_rain", StringComparer.OrdinalIgnoreCase), "mcc_recent_events did not include weather_rain.");
|
||||
Ensure(GetEventTypes(setupEventsData).Contains("title", StringComparer.OrdinalIgnoreCase), "mcc_recent_events did not include title.");
|
||||
Ensure(GetEventTypes(setupEventsData).Contains("actionbar", StringComparer.OrdinalIgnoreCase), "mcc_recent_events did not include actionbar.");
|
||||
}
|
||||
|
||||
ToolEnvelope actionbarEvents = await CallSuccessAsync(client, executed, "mcc_recent_events", new Dictionary<string, object?>
|
||||
{
|
||||
["afterId"] = 0L,
|
||||
["maxCount"] = 20,
|
||||
["typeFilter"] = "actionbar"
|
||||
});
|
||||
JsonElement actionbarData = RequireData(actionbarEvents);
|
||||
Ensure(ReadInt32(actionbarData, "count") > 0, "mcc_recent_events typeFilter=actionbar returned no events.");
|
||||
Ensure(AllEventsMatchType(actionbarData, "actionbar"), "mcc_recent_events typeFilter returned mixed event types.");
|
||||
|
||||
long inventoryBaseline = ReadInt64(actionbarData, "latestId");
|
||||
int chestX = (int)Math.Floor(worldLocation.X) + 2;
|
||||
int chestY = (int)Math.Floor(worldLocation.Y);
|
||||
int chestZ = (int)Math.Floor(worldLocation.Z);
|
||||
ToolEnvelope openContainer = await WaitForPredicateAsync(
|
||||
client,
|
||||
executed,
|
||||
"mcc_container_open_at",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["x"] = chestX,
|
||||
["y"] = chestY,
|
||||
["z"] = chestZ,
|
||||
["timeoutMs"] = 3000,
|
||||
["closeCurrent"] = true
|
||||
},
|
||||
envelope => envelope.Success,
|
||||
"mcc_open_container_at never opened the nearby chest.");
|
||||
JsonElement inventoryInfo = RequireProperty(RequireData(openContainer), "inventory");
|
||||
int openedInventoryId = ReadInt32(inventoryInfo, "id");
|
||||
ToolEnvelope closeContainer = await CallSuccessAsync(client, executed, "mcc_container_close", new Dictionary<string, object?>
|
||||
{
|
||||
["inventoryId"] = openedInventoryId,
|
||||
["timeoutMs"] = 3000
|
||||
});
|
||||
Ensure(ReadBoolean(RequireData(closeContainer), "closed"), "mcc_close_container did not close the chest.");
|
||||
|
||||
ToolEnvelope inventoryEvents = await WaitForRecentEventTypesAsync(
|
||||
client,
|
||||
executed,
|
||||
inventoryBaseline,
|
||||
"inventory_open",
|
||||
"inventory_close");
|
||||
JsonElement inventoryEventsData = RequireData(inventoryEvents);
|
||||
Ensure(GetEventTypes(inventoryEventsData).Contains("inventory_open", StringComparer.OrdinalIgnoreCase), "mcc_recent_events did not include inventory_open.");
|
||||
Ensure(GetEventTypes(inventoryEventsData).Contains("inventory_close", StringComparer.OrdinalIgnoreCase), "mcc_recent_events did not include inventory_close.");
|
||||
checks.Add("mcc_recent_events");
|
||||
|
||||
if (runLocalSetup)
|
||||
{
|
||||
long deathBaseline = ReadInt64(inventoryEventsData, "latestId");
|
||||
await RunRconCommandAsync(rconScript, rconPort, rconPassword, $"kill {botName}");
|
||||
ToolEnvelope deathEvents = await WaitForRecentEventTypesAsync(client, executed, deathBaseline, "death");
|
||||
Ensure(GetEventTypes(RequireData(deathEvents)).Contains("death", StringComparer.OrdinalIgnoreCase),
|
||||
"mcc_recent_events never reported death after the RCON kill.");
|
||||
|
||||
long respawnBaseline = ReadInt64(RequireData(deathEvents), "latestId");
|
||||
ToolEnvelope respawn = await CallSuccessAsync(client, executed, "mcc_respawn");
|
||||
Ensure(ReadBoolean(RequireData(respawn), "success"), "mcc_respawn did not report success.");
|
||||
ToolEnvelope respawnEvents = await WaitForRecentEventTypesAsync(client, executed, respawnBaseline, "respawn");
|
||||
Ensure(GetEventTypes(RequireData(respawnEvents)).Contains("respawn", StringComparer.OrdinalIgnoreCase),
|
||||
"mcc_recent_events never reported respawn after mcc_respawn.");
|
||||
checks.Add("mcc_respawn");
|
||||
}
|
||||
else
|
||||
{
|
||||
long respawnBaseline = ReadInt64(RequireData(inventoryEvents), "latestId");
|
||||
ToolEnvelope respawn = await CallSuccessAsync(client, executed, "mcc_respawn");
|
||||
Ensure(ReadBoolean(RequireData(respawn), "success"), "mcc_respawn did not report success.");
|
||||
ToolEnvelope respawnEvents = await WaitForRecentEventTypesAsync(client, executed, respawnBaseline, "respawn");
|
||||
Ensure(GetEventTypes(RequireData(respawnEvents)).Contains("respawn", StringComparer.OrdinalIgnoreCase),
|
||||
"mcc_recent_events never reported respawn after mcc_respawn.");
|
||||
checks.Add("mcc_respawn");
|
||||
}
|
||||
|
||||
ToolEnvelope loadedBots = await CallSuccessAsync(client, executed, "mcc_loaded_bots");
|
||||
JsonElement bots = RequireProperty(RequireData(loadedBots), "bots");
|
||||
Ensure(ContainsBot(bots, "McpServer"), "mcc_loaded_bots did not include McpServer.");
|
||||
checks.Add("mcc_loaded_bots");
|
||||
|
||||
ToolEnvelope disconnect = await CallSuccessAsync(client, executed, "mcc_disconnect");
|
||||
Ensure(ReadBoolean(RequireData(disconnect), "disconnecting"), "mcc_disconnect did not report disconnecting=true.");
|
||||
checks.Add("mcc_disconnect");
|
||||
|
||||
if (!useStdio)
|
||||
{
|
||||
await AssertDisconnectStopsEndpointAsync(client, executed);
|
||||
}
|
||||
|
||||
Console.WriteLine(JsonSerializer.Serialize(new
|
||||
{
|
||||
success = true,
|
||||
endpoint,
|
||||
useStdio,
|
||||
runLocalSetup,
|
||||
checks,
|
||||
executed
|
||||
}, new JsonSerializerOptions { WriteIndented = true }));
|
||||
|
||||
Environment.ExitCode = 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine(JsonSerializer.Serialize(new
|
||||
{
|
||||
success = false,
|
||||
endpoint,
|
||||
useStdio,
|
||||
runLocalSetup,
|
||||
error = ex.Message,
|
||||
checks,
|
||||
executed
|
||||
}, new JsonSerializerOptions { WriteIndented = true }));
|
||||
Environment.ExitCode = 1;
|
||||
}
|
||||
|
||||
static async Task PrepareWorldAsync(string rconScript, string rconPort, string rconPassword, string botName, Coordinate location)
|
||||
{
|
||||
string[] commands =
|
||||
[
|
||||
$"op {botName}",
|
||||
$"gamemode creative {botName}",
|
||||
$"tp {botName} 0 80 0",
|
||||
$"item replace entity {botName} hotbar.0 with minecraft:stone 32",
|
||||
$"item replace entity {botName} hotbar.1 with minecraft:diamond_sword 1",
|
||||
$"execute as {botName} at @s run setblock ~2 ~ ~ minecraft:chest",
|
||||
$"execute as {botName} at @s run summon minecraft:armor_stand ~2 ~ ~1",
|
||||
"weather clear",
|
||||
"weather rain",
|
||||
$"title {botName} title {{\"text\":\"mcp_title\"}}",
|
||||
$"title {botName} actionbar {{\"text\":\"mcp_actionbar\"}}"
|
||||
];
|
||||
|
||||
foreach (string command in commands)
|
||||
{
|
||||
await RunRconCommandAsync(rconScript, rconPort, rconPassword, command);
|
||||
}
|
||||
}
|
||||
|
||||
static async Task RunRconCommandAsync(string rconScript, string rconPort, string rconPassword, string command)
|
||||
{
|
||||
ProcessStartInfo startInfo = new("bash")
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true
|
||||
};
|
||||
startInfo.ArgumentList.Add(rconScript);
|
||||
startInfo.ArgumentList.Add(command);
|
||||
startInfo.ArgumentList.Add(rconPort);
|
||||
startInfo.ArgumentList.Add(rconPassword);
|
||||
|
||||
using Process process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start mc-rcon.sh.");
|
||||
string stdout = await process.StandardOutput.ReadToEndAsync();
|
||||
string stderr = await process.StandardError.ReadToEndAsync();
|
||||
await process.WaitForExitAsync();
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"RCON command failed ({command}): {(string.IsNullOrWhiteSpace(stderr) ? stdout : stderr).Trim()}");
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<ToolEnvelope> CallSuccessAsync(
|
||||
McpClient client,
|
||||
List<object> executed,
|
||||
string toolName,
|
||||
IReadOnlyDictionary<string, object?>? args = null)
|
||||
{
|
||||
ToolEnvelope envelope = await CallToolAsync(client, executed, toolName, args);
|
||||
if (!envelope.Success)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{toolName} failed with errorCode={envelope.ErrorCode ?? "<null>"} message={envelope.Message ?? "<null>"}.");
|
||||
}
|
||||
|
||||
return envelope;
|
||||
}
|
||||
|
||||
static async Task<ToolEnvelope> WaitForPredicateAsync(
|
||||
McpClient client,
|
||||
List<object> executed,
|
||||
string toolName,
|
||||
IReadOnlyDictionary<string, object?>? args,
|
||||
Func<ToolEnvelope, bool> predicate,
|
||||
string failureMessage,
|
||||
int maxAttempts = 12,
|
||||
int delayMs = 400)
|
||||
{
|
||||
ToolEnvelope? lastEnvelope = null;
|
||||
for (int attempt = 0; attempt < maxAttempts; attempt++)
|
||||
{
|
||||
ToolEnvelope envelope = await CallToolAsync(client, executed, toolName, args);
|
||||
lastEnvelope = envelope;
|
||||
if (predicate(envelope))
|
||||
return envelope;
|
||||
|
||||
await Task.Delay(delayMs);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"{failureMessage} Last result: success={lastEnvelope?.Success}, errorCode={lastEnvelope?.ErrorCode ?? "<null>"}.");
|
||||
}
|
||||
|
||||
static async Task<ToolEnvelope> WaitForRecentEventTypesAsync(
|
||||
McpClient client,
|
||||
List<object> executed,
|
||||
long afterId,
|
||||
params string[] expectedTypes)
|
||||
{
|
||||
HashSet<string> expected = expectedTypes.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
ToolEnvelope? lastEnvelope = null;
|
||||
|
||||
for (int attempt = 0; attempt < 12; attempt++)
|
||||
{
|
||||
ToolEnvelope envelope = await CallSuccessAsync(client, executed, "mcc_recent_events", new Dictionary<string, object?>
|
||||
{
|
||||
["afterId"] = afterId,
|
||||
["maxCount"] = 100
|
||||
});
|
||||
lastEnvelope = envelope;
|
||||
JsonElement data = RequireData(envelope);
|
||||
HashSet<string> actual = GetEventTypes(data);
|
||||
if (expected.All(actual.Contains))
|
||||
return envelope;
|
||||
|
||||
await Task.Delay(400);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException(
|
||||
$"mcc_recent_events never reported: {string.Join(", ", expectedTypes)} after event id {afterId}. Last latestId={ReadInt64(RequireData(lastEnvelope!), "latestId")}.");
|
||||
}
|
||||
|
||||
static async Task<ToolEnvelope> CallToolAsync(
|
||||
McpClient client,
|
||||
List<object> executed,
|
||||
string toolName,
|
||||
IReadOnlyDictionary<string, object?>? args = null)
|
||||
{
|
||||
CallToolResult result = await client.CallToolAsync(toolName, args);
|
||||
string responseJson = ExtractResponseJson(result);
|
||||
JsonElement root = JsonDocument.Parse(responseJson).RootElement.Clone();
|
||||
JsonElement? data = root.TryGetProperty("data", out JsonElement dataElement) ? dataElement.Clone() : null;
|
||||
bool success = root.TryGetProperty("success", out JsonElement successElement)
|
||||
&& successElement.ValueKind == JsonValueKind.True;
|
||||
string? errorCode = ReadString(root, "errorCode");
|
||||
string? message = ReadString(root, "message");
|
||||
|
||||
executed.Add(new
|
||||
{
|
||||
tool = toolName,
|
||||
arguments = args,
|
||||
isError = result.IsError,
|
||||
success,
|
||||
errorCode,
|
||||
message,
|
||||
response = root
|
||||
});
|
||||
|
||||
return new ToolEnvelope(toolName, result.IsError ?? false, success, errorCode, message, root, data);
|
||||
}
|
||||
|
||||
static async Task AssertDisconnectStopsEndpointAsync(McpClient client, List<object> executed)
|
||||
{
|
||||
for (int attempt = 0; attempt < 15; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
await CallToolAsync(client, executed, "mcc_world_state");
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(300);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("The MCP endpoint still responded after mcc_disconnect.");
|
||||
}
|
||||
|
||||
static string ExtractResponseJson(CallToolResult result)
|
||||
{
|
||||
if (result.Content is null)
|
||||
throw new InvalidOperationException("Tool response did not contain any content blocks.");
|
||||
|
||||
foreach (ContentBlock content in result.Content)
|
||||
{
|
||||
if (content is TextContentBlock text && !string.IsNullOrWhiteSpace(text.Text))
|
||||
return text.Text;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Tool response did not contain a text payload.");
|
||||
}
|
||||
|
||||
static JsonElement RequireData(ToolEnvelope envelope)
|
||||
{
|
||||
if (envelope.Data is JsonElement data)
|
||||
return data;
|
||||
|
||||
throw new InvalidOperationException($"{envelope.ToolName} returned no data payload.");
|
||||
}
|
||||
|
||||
static JsonElement RequireProperty(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.TryGetProperty(propertyName, out JsonElement property))
|
||||
return property;
|
||||
|
||||
throw new InvalidOperationException($"Missing required property '{propertyName}'.");
|
||||
}
|
||||
|
||||
static string RequireString(JsonElement element, string propertyName)
|
||||
{
|
||||
string? value = ReadString(element, propertyName);
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
|
||||
throw new InvalidOperationException($"Property '{propertyName}' is missing or empty.");
|
||||
}
|
||||
|
||||
static string? ReadString(JsonElement element, string propertyName)
|
||||
{
|
||||
return element.TryGetProperty(propertyName, out JsonElement property) && property.ValueKind == JsonValueKind.String
|
||||
? property.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
static int ReadInt32(JsonElement element, string propertyName)
|
||||
{
|
||||
JsonElement property = RequireProperty(element, propertyName);
|
||||
if (property.TryGetInt32(out int value))
|
||||
return value;
|
||||
|
||||
throw new InvalidOperationException($"Property '{propertyName}' is not an Int32.");
|
||||
}
|
||||
|
||||
static long ReadInt64(JsonElement element, string propertyName)
|
||||
{
|
||||
JsonElement property = RequireProperty(element, propertyName);
|
||||
if (property.TryGetInt64(out long value))
|
||||
return value;
|
||||
|
||||
throw new InvalidOperationException($"Property '{propertyName}' is not an Int64.");
|
||||
}
|
||||
|
||||
static double ReadDouble(JsonElement element, string propertyName)
|
||||
{
|
||||
JsonElement property = RequireProperty(element, propertyName);
|
||||
if (property.TryGetDouble(out double value))
|
||||
return value;
|
||||
|
||||
throw new InvalidOperationException($"Property '{propertyName}' is not a Double.");
|
||||
}
|
||||
|
||||
static bool ReadBoolean(JsonElement element, string propertyName)
|
||||
{
|
||||
JsonElement property = RequireProperty(element, propertyName);
|
||||
return property.ValueKind switch
|
||||
{
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
_ => throw new InvalidOperationException($"Property '{propertyName}' is not a Boolean.")
|
||||
};
|
||||
}
|
||||
|
||||
static bool HasNonNullProperty(JsonElement element, string propertyName)
|
||||
{
|
||||
return element.TryGetProperty(propertyName, out JsonElement property) && property.ValueKind != JsonValueKind.Null;
|
||||
}
|
||||
|
||||
static Coordinate ReadCoordinate(JsonElement element, string propertyName)
|
||||
{
|
||||
JsonElement coordinate = RequireProperty(element, propertyName);
|
||||
return new Coordinate(
|
||||
ReadDouble(coordinate, "x"),
|
||||
ReadDouble(coordinate, "y"),
|
||||
ReadDouble(coordinate, "z"));
|
||||
}
|
||||
|
||||
static JsonElement FindPlayer(JsonElement players, string playerName)
|
||||
{
|
||||
foreach (JsonElement player in players.EnumerateArray())
|
||||
{
|
||||
string? name = ReadString(player, "name");
|
||||
if (string.Equals(name, playerName, StringComparison.OrdinalIgnoreCase))
|
||||
return player;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Could not find player '{playerName}' in mcc_players_detailed.");
|
||||
}
|
||||
|
||||
static bool ContainsItemType(JsonElement searchData, string itemType)
|
||||
{
|
||||
JsonElement matches = RequireProperty(searchData, "matches");
|
||||
foreach (JsonElement match in matches.EnumerateArray())
|
||||
{
|
||||
if (string.Equals(ReadString(match, "itemType"), itemType, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool ContainsBot(JsonElement bots, string botName)
|
||||
{
|
||||
foreach (JsonElement bot in bots.EnumerateArray())
|
||||
{
|
||||
if (string.Equals(ReadString(bot, "name"), botName, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static HashSet<string> GetEventTypes(JsonElement recentEventsData)
|
||||
{
|
||||
JsonElement events = RequireProperty(recentEventsData, "events");
|
||||
return events.EnumerateArray()
|
||||
.Select(entry => RequireString(entry, "type"))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
static bool AllEventsMatchType(JsonElement recentEventsData, string type)
|
||||
{
|
||||
JsonElement events = RequireProperty(recentEventsData, "events");
|
||||
foreach (JsonElement entry in events.EnumerateArray())
|
||||
{
|
||||
if (!string.Equals(RequireString(entry, "type"), type, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static void Ensure(bool condition, string message)
|
||||
{
|
||||
if (!condition)
|
||||
throw new InvalidOperationException(message);
|
||||
}
|
||||
|
||||
static bool IsLocalEndpoint(string endpoint)
|
||||
{
|
||||
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out Uri? uri))
|
||||
return false;
|
||||
|
||||
return string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(uri.Host, "127.0.0.1", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(uri.Host, "::1", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
static string FindRepoRoot()
|
||||
{
|
||||
string current = Directory.GetCurrentDirectory();
|
||||
DirectoryInfo? directory = new(current);
|
||||
|
||||
while (directory is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(directory.FullName, "MinecraftClient.sln")))
|
||||
return directory.FullName;
|
||||
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
static StdioClientTransportOptions CreateStdioOptions()
|
||||
{
|
||||
string? stdioBin = Environment.GetEnvironmentVariable("MCC_MCP_STDIO_BIN");
|
||||
if (!string.IsNullOrWhiteSpace(stdioBin))
|
||||
{
|
||||
return new StdioClientTransportOptions
|
||||
{
|
||||
Name = "MCC MCP Stdio Harness",
|
||||
Command = stdioBin,
|
||||
Arguments = [],
|
||||
ShutdownTimeout = TimeSpan.FromSeconds(5)
|
||||
};
|
||||
}
|
||||
|
||||
return new StdioClientTransportOptions
|
||||
{
|
||||
Name = "MCC MCP Stdio Harness",
|
||||
Command = "dotnet",
|
||||
Arguments =
|
||||
[
|
||||
"run",
|
||||
"--project",
|
||||
"DebugTools/MccMcpStdioHarness",
|
||||
"-c",
|
||||
"Release",
|
||||
"--no-build"
|
||||
],
|
||||
ShutdownTimeout = TimeSpan.FromSeconds(5)
|
||||
};
|
||||
}
|
||||
|
||||
internal readonly record struct Coordinate(double X, double Y, double Z);
|
||||
|
||||
internal sealed record ToolEnvelope(
|
||||
string ToolName,
|
||||
bool IsError,
|
||||
bool Success,
|
||||
string? ErrorCode,
|
||||
string? Message,
|
||||
JsonElement Root,
|
||||
JsonElement? Data);
|
||||
18
DebugTools/MccMcpStdioHarness/MccMcpStdioHarness.csproj
Normal file
18
DebugTools/MccMcpStdioHarness/MccMcpStdioHarness.csproj
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ModelContextProtocol" Version="1.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\MinecraftClient\MinecraftClient.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
984
DebugTools/MccMcpStdioHarness/Program.cs
Normal file
984
DebugTools/MccMcpStdioHarness/Program.cs
Normal file
|
|
@ -0,0 +1,984 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MinecraftClient.Mcp;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
builder.Logging.AddConsole(options =>
|
||||
{
|
||||
options.LogToStandardErrorThreshold = LogLevel.Trace;
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton(new MccMcpConfig());
|
||||
builder.Services.AddSingleton<IMccMcpCapabilities, DeterministicCapabilities>();
|
||||
builder.Services.AddSingleton<MccMcpGuidanceProvider>();
|
||||
builder.Services.AddMcpServer()
|
||||
.WithStdioServerTransport()
|
||||
.WithTools<MccMcpToolSet>()
|
||||
.WithPrompts<MccMcpPromptSet>();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
|
||||
internal sealed class DeterministicCapabilities : IMccMcpCapabilities
|
||||
{
|
||||
private static double C(double value) => Math.Round(value, 2, MidpointRounding.AwayFromZero);
|
||||
|
||||
private readonly List<RecentEvent> recentEvents = [];
|
||||
private long nextEventId = 1;
|
||||
private double playerX = C(0.5);
|
||||
private double playerY = C(80.0);
|
||||
private double playerZ = C(0.5);
|
||||
private float yaw;
|
||||
private float pitch;
|
||||
private int currentSlot = 1;
|
||||
private bool sneaking;
|
||||
private bool sprinting;
|
||||
private float health = 20.0f;
|
||||
private bool disconnecting;
|
||||
|
||||
public DeterministicCapabilities()
|
||||
{
|
||||
AddRecentEvent("player_join", new { name = "HarnessBot" });
|
||||
AddRecentEvent("inventory_open", new { inventoryId = 1, type = "Generic_9x3", title = "Chest" });
|
||||
AddRecentEvent("weather_rain", new { level = 1.0 });
|
||||
AddRecentEvent("title", new { text = "mcp_title" });
|
||||
AddRecentEvent("actionbar", new { text = "mcp_actionbar" });
|
||||
}
|
||||
|
||||
public MccMcpResult GetSessionStatus() =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
connected = !disconnecting,
|
||||
host = "deterministic.local",
|
||||
port = 25565,
|
||||
username = "HarnessBot",
|
||||
location = new { x = playerX, y = playerY, z = playerZ }
|
||||
});
|
||||
|
||||
public MccMcpResult GetServerInfo() =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
host = "deterministic.local",
|
||||
port = 25565,
|
||||
tps = 20.0
|
||||
});
|
||||
|
||||
public MccMcpResult GetPlayerState() =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
nickname = "HarnessBot",
|
||||
username = "HarnessBot",
|
||||
health,
|
||||
saturation = 20,
|
||||
gamemode = 1,
|
||||
currentSlot,
|
||||
yaw,
|
||||
pitch,
|
||||
location = new { x = playerX, y = playerY, z = playerZ },
|
||||
effects = new object[0]
|
||||
});
|
||||
|
||||
public MccMcpResult GetWorldState() =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
connected = !disconnecting,
|
||||
host = "deterministic.local",
|
||||
port = 25565,
|
||||
username = "HarnessBot",
|
||||
protocol = 769,
|
||||
terrainEnabled = true,
|
||||
inventoryEnabled = true,
|
||||
entityHandlingEnabled = true,
|
||||
location = new { x = playerX, y = playerY, z = playerZ },
|
||||
tps = 20.0,
|
||||
dimension = "minecraft:overworld",
|
||||
loadedChunkCount = 9,
|
||||
pendingChunkCount = 0,
|
||||
totalChunkCount = 9,
|
||||
loadRatio = 1.0,
|
||||
worldAge = 12000L,
|
||||
timeOfDay = 6000L,
|
||||
rainLevel = 1.0,
|
||||
thunderLevel = 0.0
|
||||
});
|
||||
|
||||
public MccMcpResult GetChunkStatus(double? x, double? y, double? z)
|
||||
{
|
||||
double resolvedX = x ?? playerX;
|
||||
double resolvedY = y ?? playerY;
|
||||
double resolvedZ = z ?? playerZ;
|
||||
int chunkX = (int)Math.Floor(resolvedX) >> 4;
|
||||
int chunkZ = (int)Math.Floor(resolvedZ) >> 4;
|
||||
|
||||
return MccMcpResult.Ok(new
|
||||
{
|
||||
location = new { x = C(resolvedX), y = C(resolvedY), z = C(resolvedZ) },
|
||||
chunk = new { x = chunkX, z = chunkZ },
|
||||
loaded = true,
|
||||
fullyLoaded = true,
|
||||
loadedChunkCount = 9,
|
||||
pendingChunkCount = 0,
|
||||
totalChunkCount = 9,
|
||||
loadRatio = 1.0
|
||||
});
|
||||
}
|
||||
|
||||
public MccMcpResult RaycastBlock(double maxDistance, bool includeNeighbors)
|
||||
{
|
||||
object? neighbors = includeNeighbors
|
||||
? new
|
||||
{
|
||||
north = new { x = 0, y = 79, z = -1, material = "Air", typeLabel = "Air" },
|
||||
south = new { x = 0, y = 79, z = 1, material = "Air", typeLabel = "Air" },
|
||||
east = new { x = 1, y = 79, z = 0, material = "Air", typeLabel = "Air" },
|
||||
west = new { x = -1, y = 79, z = 0, material = "Air", typeLabel = "Air" },
|
||||
above = new { x = 0, y = 80, z = 0, material = "Air", typeLabel = "Air" },
|
||||
below = new { x = 0, y = 78, z = 0, material = "Stone", typeLabel = "Stone" }
|
||||
}
|
||||
: null;
|
||||
|
||||
return MccMcpResult.Ok(new
|
||||
{
|
||||
hit = true,
|
||||
maxDistance,
|
||||
playerLocation = new { x = playerX, y = playerY, z = playerZ },
|
||||
eyeLocation = new { x = playerX, y = C(playerY + 1.62), z = playerZ },
|
||||
location = new { x = 0, y = 79, z = 0 },
|
||||
block = new { material = "Stone", typeLabel = "Stone", blockId = 1, blockMeta = 0 },
|
||||
distance = 1.12,
|
||||
eyeDistance = 2.03,
|
||||
neighbors
|
||||
});
|
||||
}
|
||||
|
||||
public MccMcpResult PreviewPath(double x, double y, double z, bool allowUnsafe, int maxOffset, int minOffset, int timeoutMs, int maxWaypoints)
|
||||
{
|
||||
object[] waypoints =
|
||||
[
|
||||
new { x = playerX, y = playerY, z = playerZ },
|
||||
new { x = C((playerX + x) / 2), y = C((playerY + y) / 2), z = C((playerZ + z) / 2) },
|
||||
new { x = C(x), y = C(y), z = C(z) }
|
||||
];
|
||||
|
||||
return MccMcpResult.Ok(new
|
||||
{
|
||||
pathFound = true,
|
||||
exactReachable = true,
|
||||
target = new { x = C(x), y = C(y), z = C(z) },
|
||||
startLocation = new { x = playerX, y = playerY, z = playerZ },
|
||||
finalWaypoint = new { x = C(x), y = C(y), z = C(z) },
|
||||
finalDistance = 0.0,
|
||||
waypointCount = waypoints.Length,
|
||||
truncated = waypoints.Length > Math.Max(1, maxWaypoints),
|
||||
waypoints = waypoints.Take(Math.Max(1, maxWaypoints)).ToArray(),
|
||||
allowUnsafe,
|
||||
maxOffset,
|
||||
minOffset,
|
||||
timeoutMs = timeoutMs <= 0 ? 5000 : timeoutMs
|
||||
});
|
||||
}
|
||||
|
||||
public MccMcpResult GetPlayersList() =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
players = new[] { "HarnessBot", "PlayerOne" }
|
||||
});
|
||||
|
||||
public MccMcpResult GetPlayersDetailed(bool includeSelf, bool includeCoordinates)
|
||||
{
|
||||
List<object> players = [];
|
||||
if (includeSelf)
|
||||
{
|
||||
players.Add(new
|
||||
{
|
||||
name = "HarnessBot",
|
||||
uuid = Guid.Parse("11111111-1111-1111-1111-111111111111"),
|
||||
ping = 5,
|
||||
gamemode = 1,
|
||||
listed = true,
|
||||
displayName = "HarnessBot",
|
||||
entityId = 1,
|
||||
x = includeCoordinates ? playerX : (double?)null,
|
||||
y = includeCoordinates ? playerY : (double?)null,
|
||||
z = includeCoordinates ? playerZ : (double?)null
|
||||
});
|
||||
}
|
||||
|
||||
players.Add(new
|
||||
{
|
||||
name = "PlayerOne",
|
||||
uuid = Guid.Parse("22222222-2222-2222-2222-222222222222"),
|
||||
ping = 12,
|
||||
gamemode = 1,
|
||||
listed = true,
|
||||
displayName = "PlayerOne",
|
||||
entityId = 2,
|
||||
x = includeCoordinates ? C(3.5) : (double?)null,
|
||||
y = includeCoordinates ? C(80.0) : (double?)null,
|
||||
z = includeCoordinates ? C(0.5) : (double?)null
|
||||
});
|
||||
|
||||
return MccMcpResult.Ok(new
|
||||
{
|
||||
count = players.Count,
|
||||
players = players.ToArray()
|
||||
});
|
||||
}
|
||||
|
||||
public MccMcpResult GetPlayerStats() =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
health,
|
||||
saturation = 20,
|
||||
level = 12,
|
||||
totalExperience = 245,
|
||||
gamemode = 1,
|
||||
playerEntityId = 1,
|
||||
currentSlot,
|
||||
yaw,
|
||||
pitch,
|
||||
sneaking,
|
||||
sprinting,
|
||||
location = new { x = playerX, y = playerY, z = playerZ },
|
||||
tps = 20.0
|
||||
});
|
||||
|
||||
public MccMcpResult GetStatusEffects() =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
count = 0,
|
||||
effects = Array.Empty<object>()
|
||||
});
|
||||
|
||||
public MccMcpResult GetRecentEvents(long afterId, int maxCount, string? typeFilter)
|
||||
{
|
||||
RecentEvent[] events = recentEvents
|
||||
.Where(e => e.Id > afterId)
|
||||
.Where(e => string.IsNullOrWhiteSpace(typeFilter) || string.Equals(e.Type, typeFilter, StringComparison.OrdinalIgnoreCase))
|
||||
.Take(Math.Max(1, maxCount))
|
||||
.ToArray();
|
||||
|
||||
return MccMcpResult.Ok(new
|
||||
{
|
||||
afterId,
|
||||
latestId = recentEvents.Count > 0 ? recentEvents[^1].Id : 0,
|
||||
count = events.Length,
|
||||
events = events.Select(e => new
|
||||
{
|
||||
id = e.Id,
|
||||
timestampUtc = e.TimestampUtc,
|
||||
type = e.Type,
|
||||
data = e.Data
|
||||
}).ToArray()
|
||||
});
|
||||
}
|
||||
|
||||
public MccMcpResult GetLoadedBots() =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
count = 2,
|
||||
bots = new object[]
|
||||
{
|
||||
new { name = "McpServer", fullTypeName = "MinecraftClient.ChatBots.McpServer", isScript = false },
|
||||
new { name = "HarnessScript", fullTypeName = "MinecraftClient.ChatBots.Script", isScript = true }
|
||||
}
|
||||
});
|
||||
|
||||
public MccMcpResult GetChatHistory(int maxCount, bool includeJson) =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
count = 2,
|
||||
entries = new object[]
|
||||
{
|
||||
new { timestampUtc = DateTimeOffset.UtcNow.AddSeconds(-10), kind = "chat", text = "<PlayerOne> hello", sender = "PlayerOne", message = "hello", json = includeJson ? "{}" : null },
|
||||
new { timestampUtc = DateTimeOffset.UtcNow.AddSeconds(-5), kind = "system", text = "HarnessBot joined the game", sender = (string?)null, message = (string?)null, json = includeJson ? "{}" : null }
|
||||
}
|
||||
});
|
||||
|
||||
public MccMcpResult GetInternalCommands() =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
count = 4,
|
||||
commands = new[]
|
||||
{
|
||||
new { name = "debug", usage = "debug [on|off|state]", description = "Toggle debug or print state." },
|
||||
new { name = "move", usage = "move <x> <y> <z>", description = "Move to location." },
|
||||
new { name = "useitem", usage = "useitem [x] [y] [z]", description = "Use current held item." },
|
||||
new { name = "dig", usage = "dig <x> <y> <z> [duration]", description = "Dig block at location." }
|
||||
}
|
||||
});
|
||||
|
||||
public MccMcpResult GetMaterialsList(string? filter, int maxCount) =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
total = 3,
|
||||
count = 3,
|
||||
filter,
|
||||
materials = new[]
|
||||
{
|
||||
new { name = "Air", typeLabel = "Air" },
|
||||
new { name = "GrassBlock", typeLabel = "Grass Block" },
|
||||
new { name = "OakLog", typeLabel = "Oak Log" }
|
||||
}
|
||||
});
|
||||
|
||||
public MccMcpResult GetBlockTypesList(string? filter, int maxCount) =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
total = 3,
|
||||
count = 3,
|
||||
filter,
|
||||
blockTypes = new[]
|
||||
{
|
||||
new { name = "Air", typeLabel = "Air" },
|
||||
new { name = "GrassBlock", typeLabel = "Grass Block" },
|
||||
new { name = "OakLog", typeLabel = "Oak Log" }
|
||||
}
|
||||
});
|
||||
|
||||
public MccMcpResult GetEntityTypesList(string? filter, int maxCount) =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
total = 3,
|
||||
count = 3,
|
||||
filter,
|
||||
entityTypes = new[]
|
||||
{
|
||||
new { name = "Player", typeLabel = "Player" },
|
||||
new { name = "Item", typeLabel = "Item" },
|
||||
new { name = "Villager", typeLabel = "Villager" }
|
||||
}
|
||||
});
|
||||
|
||||
public MccMcpResult SendChat(string text) =>
|
||||
MccMcpResult.Ok(new { echoed = text });
|
||||
|
||||
public MccMcpResult QuitClient() =>
|
||||
MccMcpResult.Ok(new { quitting = true });
|
||||
|
||||
public MccMcpResult DisconnectClient()
|
||||
{
|
||||
disconnecting = true;
|
||||
AddRecentEvent("disconnect", new { reason = "requested", message = "Disconnect requested by test client." });
|
||||
return MccMcpResult.Ok(new { disconnecting = true });
|
||||
}
|
||||
|
||||
public MccMcpResult RunInternalCommand(string command) =>
|
||||
MccMcpResult.Ok(new { command, status = "Done", output = "deterministic" });
|
||||
|
||||
public MccMcpResult UseItemOnHand() =>
|
||||
MccMcpResult.Ok(new { success = true, action = "use_item_on_hand" });
|
||||
|
||||
public MccMcpResult ChangeHotbarSlot(int slot)
|
||||
{
|
||||
currentSlot = slot;
|
||||
return MccMcpResult.Ok(new { success = true, slot });
|
||||
}
|
||||
|
||||
public MccMcpResult SelectHotbarItem(string itemType, bool preferLowestSlot)
|
||||
{
|
||||
currentSlot = string.Equals(itemType, "DiamondSword", StringComparison.OrdinalIgnoreCase) ? 2 : 1;
|
||||
return MccMcpResult.Ok(new
|
||||
{
|
||||
success = true,
|
||||
itemType,
|
||||
inventorySlot = currentSlot - 1,
|
||||
selectedSlot = currentSlot,
|
||||
count = string.Equals(itemType, "DiamondSword", StringComparison.OrdinalIgnoreCase) ? 1 : 32,
|
||||
preferLowestSlot
|
||||
});
|
||||
}
|
||||
|
||||
public MccMcpResult UseItemOnBlock(double x, double y, double z) =>
|
||||
MccMcpResult.Ok(new { success = true, x = C(x), y = C(y), z = C(z), action = "useitem" });
|
||||
|
||||
public MccMcpResult DigBlock(double x, double y, double z, double durationSeconds) =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
success = true,
|
||||
target = new { x = C(x), y = C(y), z = C(z) },
|
||||
beforeBlock = new { material = "OakLog", typeLabel = "Oak Log", blockId = 137, blockMeta = 0 },
|
||||
afterBlock = new { material = "Air", typeLabel = "Air", blockId = 0, blockMeta = 0 },
|
||||
commandAccepted = true,
|
||||
changed = true,
|
||||
destroyed = true,
|
||||
attempts = 1,
|
||||
attemptedDurationsSeconds = new[] { durationSeconds > 0 ? durationSeconds : 1.5 },
|
||||
distance = 1.5,
|
||||
playerLocation = new { x = C(0.5), y = C(80.0), z = C(0.5) }
|
||||
});
|
||||
|
||||
public MccMcpResult PlaceBlock(int x, int y, int z, string face, string hand, bool lookAtBlock) =>
|
||||
MccMcpResult.Ok(new { success = true, x, y, z, face, hand, lookAtBlock, action = "place_block" });
|
||||
|
||||
public MccMcpResult InteractEntity(int entityId, string interaction, string hand) =>
|
||||
MccMcpResult.Ok(new { success = true, entityId, interaction, hand });
|
||||
|
||||
public MccMcpResult AttackEntity(int entityId) =>
|
||||
MccMcpResult.Ok(new { success = true, entityId, interaction = "Attack" });
|
||||
|
||||
public MccMcpResult FindNearestEntity(string? typeFilter, string? nameFilter, double radius, bool includePlayers)
|
||||
{
|
||||
bool wantsArmorStand = string.IsNullOrWhiteSpace(typeFilter)
|
||||
|| string.Equals(typeFilter, "ArmorStand", StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(typeFilter, "Armor Stand", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (wantsArmorStand && radius >= 4.0)
|
||||
{
|
||||
return MccMcpResult.Ok(new
|
||||
{
|
||||
id = 7,
|
||||
type = "ArmorStand",
|
||||
typeLabel = "Armor Stand",
|
||||
uuid = Guid.Parse("33333333-3333-3333-3333-333333333333"),
|
||||
name = "Armor Stand",
|
||||
customName = (string?)null,
|
||||
x = C(2.5),
|
||||
y = C(80.0),
|
||||
z = C(0.5),
|
||||
distance = 2.0,
|
||||
health = 20.0f,
|
||||
pose = "Standing",
|
||||
latency = 0
|
||||
});
|
||||
}
|
||||
|
||||
if (includePlayers && radius >= 3.0)
|
||||
{
|
||||
return MccMcpResult.Ok(new
|
||||
{
|
||||
id = 2,
|
||||
type = "Player",
|
||||
typeLabel = "Player",
|
||||
uuid = Guid.Parse("22222222-2222-2222-2222-222222222222"),
|
||||
name = string.IsNullOrWhiteSpace(nameFilter) ? "PlayerOne" : nameFilter,
|
||||
customName = (string?)null,
|
||||
x = C(3.5),
|
||||
y = C(80.0),
|
||||
z = C(0.5),
|
||||
distance = 3.0,
|
||||
health = 20.0f,
|
||||
pose = "Standing",
|
||||
latency = 12
|
||||
});
|
||||
}
|
||||
|
||||
return MccMcpResult.Fail("invalid_state", data: new { typeFilter, nameFilter, radius, includePlayers });
|
||||
}
|
||||
|
||||
public MccMcpResult ScanNearbyBlocks(int radius, int maxCount, string? materialFilter) =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
center = new { x = 0, y = 79, z = 0 },
|
||||
radius,
|
||||
count = 1,
|
||||
blocks = new[]
|
||||
{
|
||||
new { x = 0, y = 79, z = 0, material = materialFilter ?? "GrassBlock", blockId = 9, blockMeta = 0, distance = 0.0 }
|
||||
}
|
||||
});
|
||||
|
||||
public MccMcpResult FindBlocks(string? query, int radius, int maxCount, bool exactMatch) =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
center = new { x = 0, y = 79, z = 0 },
|
||||
radius,
|
||||
query,
|
||||
exactMatch,
|
||||
count = 2,
|
||||
blocks = new object[]
|
||||
{
|
||||
new { x = 1, y = 79, z = 0, material = "GrassBlock", typeLabel = "Grass Block", blockId = 9, blockMeta = 0, distance = 1.0 },
|
||||
new { x = 2, y = 79, z = 0, material = "Dirt", typeLabel = "Dirt", blockId = 10, blockMeta = 0, distance = 2.0 }
|
||||
}
|
||||
});
|
||||
|
||||
public MccMcpResult IsPlayerNearby(string? playerName, double radius, bool includeSelf) =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
radius,
|
||||
playerName,
|
||||
includeSelf,
|
||||
anyNearby = true,
|
||||
count = 1,
|
||||
players = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
entityId = 1,
|
||||
uuid = Guid.Empty,
|
||||
name = "PlayerOne",
|
||||
customName = (string?)null,
|
||||
x = C(3.5),
|
||||
y = C(80.0),
|
||||
z = C(0.5),
|
||||
distance = 3.0,
|
||||
latency = 5
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
public MccMcpResult LocatePlayer(string playerName, bool includeSelf) =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
playerName,
|
||||
matchedName = "PlayerOne",
|
||||
entityId = 1,
|
||||
uuid = Guid.Empty,
|
||||
x = C(3.5),
|
||||
y = C(80.0),
|
||||
z = C(0.5),
|
||||
distance = 3.0
|
||||
});
|
||||
|
||||
public MccMcpResult CanReachPosition(double x, double y, double z, bool allowUnsafe, int maxOffset, int minOffset, int timeoutMs) =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
reachable = true,
|
||||
exactReachable = true,
|
||||
target = new { x = C(x), y = C(y), z = C(z) },
|
||||
startLocation = new { x = C(0.5), y = C(80.0), z = C(0.5) },
|
||||
finalWaypoint = new { x = C(x), y = C(y), z = C(z) },
|
||||
finalDistance = 0.0,
|
||||
waypointCount = 4,
|
||||
allowUnsafe,
|
||||
maxOffset,
|
||||
minOffset,
|
||||
timeoutMs = timeoutMs <= 0 ? 5000 : timeoutMs
|
||||
});
|
||||
|
||||
public MccMcpResult MoveTo(double x, double y, double z, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs) =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
pathFound = true,
|
||||
arrived = true,
|
||||
tolerance = 1.5,
|
||||
verifyWaitMs = 250,
|
||||
target = new { x = C(x), y = C(y), z = C(z) },
|
||||
startLocation = new { x = C(0.5), y = C(80.0), z = C(0.5) },
|
||||
finalLocation = new { x = C(x), y = C(y), z = C(z) },
|
||||
finalDistance = 0.0,
|
||||
distanceMoved = 3.0,
|
||||
allowUnsafe,
|
||||
allowDirectTeleport,
|
||||
maxOffset,
|
||||
minOffset,
|
||||
timeoutMs
|
||||
});
|
||||
|
||||
public MccMcpResult MoveToPlayer(string playerName, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs) =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
pathFound = true,
|
||||
arrived = true,
|
||||
tolerance = 1.5,
|
||||
verifyWaitMs = 250,
|
||||
target = new
|
||||
{
|
||||
playerName = "PlayerOne",
|
||||
entityId = 1,
|
||||
x = C(3.5),
|
||||
y = C(80.0),
|
||||
z = C(0.5)
|
||||
},
|
||||
startLocation = new { x = C(0.5), y = C(80.0), z = C(0.5) },
|
||||
finalLocation = new { x = C(3.5), y = C(80.0), z = C(0.5) },
|
||||
finalDistance = 0.0,
|
||||
distanceMoved = 3.0,
|
||||
allowUnsafe,
|
||||
allowDirectTeleport,
|
||||
maxOffset,
|
||||
minOffset,
|
||||
timeoutMs
|
||||
});
|
||||
|
||||
public MccMcpResult LookAt(double x, double y, double z) =>
|
||||
MccMcpResult.Ok(new { looked = true, x = C(x), y = C(y), z = C(z) });
|
||||
|
||||
public MccMcpResult LookDirection(string direction)
|
||||
{
|
||||
switch (direction.Trim().ToLowerInvariant())
|
||||
{
|
||||
case "up":
|
||||
yaw = 0.0f;
|
||||
pitch = -90.0f;
|
||||
break;
|
||||
case "down":
|
||||
yaw = 0.0f;
|
||||
pitch = 90.0f;
|
||||
break;
|
||||
case "north":
|
||||
yaw = 180.0f;
|
||||
pitch = 0.0f;
|
||||
break;
|
||||
case "south":
|
||||
yaw = 0.0f;
|
||||
pitch = 0.0f;
|
||||
break;
|
||||
case "east":
|
||||
yaw = -90.0f;
|
||||
pitch = 0.0f;
|
||||
break;
|
||||
case "west":
|
||||
yaw = 90.0f;
|
||||
pitch = 0.0f;
|
||||
break;
|
||||
}
|
||||
|
||||
return MccMcpResult.Ok(new { success = true, direction, yaw, pitch });
|
||||
}
|
||||
|
||||
public MccMcpResult LookAngles(float yaw, float pitch)
|
||||
{
|
||||
this.yaw = yaw;
|
||||
this.pitch = pitch;
|
||||
return MccMcpResult.Ok(new { success = true, yaw, pitch });
|
||||
}
|
||||
|
||||
public MccMcpResult PlayAnimation(string hand) =>
|
||||
MccMcpResult.Ok(new { success = true, hand });
|
||||
|
||||
public MccMcpResult ToggleSneak(bool enabled)
|
||||
{
|
||||
sneaking = enabled;
|
||||
return MccMcpResult.Ok(new { success = true, enabled = sneaking });
|
||||
}
|
||||
|
||||
public MccMcpResult ToggleSprint(bool enabled)
|
||||
{
|
||||
sprinting = enabled;
|
||||
return MccMcpResult.Ok(new { success = true, enabled = sprinting });
|
||||
}
|
||||
|
||||
public MccMcpResult ListInventories() =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
count = 2,
|
||||
inventories = new object[]
|
||||
{
|
||||
new { id = 0, type = "PlayerInventory", title = "Player Inventory", slotCount = 46, nonEmptySlots = 1, active = false },
|
||||
new { id = 1, type = "Generic_9x3", title = "Chest", slotCount = 63, nonEmptySlots = 2, active = true }
|
||||
}
|
||||
});
|
||||
|
||||
public MccMcpResult GetInventorySnapshot(int inventoryId) =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
id = inventoryId,
|
||||
type = inventoryId == 0 ? "PlayerInventory" : "Generic_9x3",
|
||||
title = inventoryId == 0 ? "Player Inventory" : "Chest",
|
||||
slotCount = inventoryId == 0 ? 46 : 63,
|
||||
slots = new[]
|
||||
{
|
||||
new { slot = 0, type = "Stone", count = 64 }
|
||||
}
|
||||
});
|
||||
|
||||
public MccMcpResult SearchInventories(string query, int maxCount, bool exactMatch, bool includeContainers)
|
||||
{
|
||||
List<object> matches = [];
|
||||
|
||||
if (query.Contains("stone", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
matches.Add(new
|
||||
{
|
||||
inventoryId = 0,
|
||||
inventoryType = "PlayerInventory",
|
||||
inventoryTitle = "Player Inventory",
|
||||
slot = 0,
|
||||
itemType = "Stone",
|
||||
typeLabel = "Stone",
|
||||
count = 32,
|
||||
isPlayerInventory = true,
|
||||
hotbarSlot = 1
|
||||
});
|
||||
}
|
||||
|
||||
if (query.Contains("diamond", StringComparison.OrdinalIgnoreCase) || query.Contains("sword", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
matches.Add(new
|
||||
{
|
||||
inventoryId = 0,
|
||||
inventoryType = "PlayerInventory",
|
||||
inventoryTitle = "Player Inventory",
|
||||
slot = 1,
|
||||
itemType = "DiamondSword",
|
||||
typeLabel = "Diamond Sword",
|
||||
count = 1,
|
||||
isPlayerInventory = true,
|
||||
hotbarSlot = 2
|
||||
});
|
||||
}
|
||||
|
||||
if (includeContainers)
|
||||
{
|
||||
matches.Add(new
|
||||
{
|
||||
inventoryId = 1,
|
||||
inventoryType = "Generic_9x3",
|
||||
inventoryTitle = "Chest",
|
||||
slot = 0,
|
||||
itemType = "Stone",
|
||||
typeLabel = "Stone",
|
||||
count = 16,
|
||||
isPlayerInventory = false,
|
||||
hotbarSlot = (int?)null
|
||||
});
|
||||
}
|
||||
|
||||
object[] result = matches.Take(Math.Max(1, maxCount)).ToArray();
|
||||
return MccMcpResult.Ok(new
|
||||
{
|
||||
query,
|
||||
exactMatch,
|
||||
includeContainers,
|
||||
count = result.Length,
|
||||
matches = result
|
||||
});
|
||||
}
|
||||
|
||||
public MccMcpResult OpenContainerAt(int x, int y, int z, int timeoutMs, bool closeCurrent)
|
||||
{
|
||||
AddRecentEvent("inventory_open", new { inventoryId = 1, type = "Generic_9x3", title = "Chest", x, y, z });
|
||||
return MccMcpResult.Ok(new
|
||||
{
|
||||
success = true,
|
||||
openAccepted = true,
|
||||
opened = true,
|
||||
timeoutMs = timeoutMs <= 0 ? 5000 : timeoutMs,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
block = new { material = "Chest", typeLabel = "Chest", blockId = 0, blockMeta = 0 },
|
||||
inventory = new { id = 1, type = "Generic_9x3", title = "Chest", slotCount = 63, nonEmptySlots = 2 }
|
||||
});
|
||||
}
|
||||
|
||||
public MccMcpResult CloseContainer(int inventoryId, int timeoutMs)
|
||||
{
|
||||
int resolvedInventoryId = inventoryId <= 0 ? 1 : inventoryId;
|
||||
AddRecentEvent("inventory_close", new { inventoryId = resolvedInventoryId });
|
||||
return MccMcpResult.Ok(new
|
||||
{
|
||||
success = true,
|
||||
closed = true,
|
||||
inventoryId = resolvedInventoryId,
|
||||
timeoutMs = timeoutMs <= 0 ? 5000 : timeoutMs
|
||||
});
|
||||
}
|
||||
|
||||
public MccMcpResult InventoryWindowAction(int inventoryId, int slotId, string actionType) =>
|
||||
MccMcpResult.Ok(new { success = true, inventoryId, slotId, actionType });
|
||||
|
||||
public MccMcpResult DropInventoryItem(string itemType, int count, int inventoryId, bool preferStack) =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
success = true,
|
||||
itemType,
|
||||
requestedCount = count,
|
||||
droppedCount = count,
|
||||
beforeCount = 64,
|
||||
afterCount = Math.Max(0, 64 - count),
|
||||
inventoryId,
|
||||
touchedSlots = new[] { 36 },
|
||||
preferStack
|
||||
});
|
||||
|
||||
public MccMcpResult DepositContainerItem(string itemType, int count, int inventoryId, bool preferLargestStack) =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
success = true,
|
||||
direction = "deposit",
|
||||
itemType,
|
||||
requestedCount = count,
|
||||
movedCount = count,
|
||||
beforePlayerCount = 64,
|
||||
afterPlayerCount = Math.Max(0, 64 - count),
|
||||
beforeContainerCount = 0,
|
||||
afterContainerCount = count,
|
||||
inventoryId = inventoryId <= 0 ? 1 : inventoryId,
|
||||
containerType = "Generic_9x3",
|
||||
touchedSourceSlots = new[] { 36 },
|
||||
touchedTargetSlots = new[] { 0 }
|
||||
});
|
||||
|
||||
public MccMcpResult WithdrawContainerItem(string itemType, int count, int inventoryId, bool preferLargestStack) =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
success = true,
|
||||
direction = "withdraw",
|
||||
itemType,
|
||||
requestedCount = count,
|
||||
movedCount = count,
|
||||
beforePlayerCount = 0,
|
||||
afterPlayerCount = count,
|
||||
beforeContainerCount = 64,
|
||||
afterContainerCount = Math.Max(0, 64 - count),
|
||||
inventoryId = inventoryId <= 0 ? 1 : inventoryId,
|
||||
containerType = "Generic_9x3",
|
||||
touchedSourceSlots = new[] { 0 },
|
||||
touchedTargetSlots = new[] { 36 }
|
||||
});
|
||||
|
||||
public MccMcpResult QueryEntities(int maxCount) =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
count = 1,
|
||||
entities = new[]
|
||||
{
|
||||
new { id = 1, type = "Player", x = C(0.5), y = C(80.0), z = C(0.5) }
|
||||
}
|
||||
});
|
||||
|
||||
public MccMcpResult ListEntities(int maxCount, string? typeFilter, double radius) =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
totalTracked = 1,
|
||||
count = 1,
|
||||
entities = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
id = 1,
|
||||
type = "Player",
|
||||
typeLabel = "Player",
|
||||
uuid = Guid.Empty,
|
||||
name = "HarnessBot",
|
||||
customName = (string?)null,
|
||||
x = C(0.5),
|
||||
y = C(80.0),
|
||||
z = C(0.5),
|
||||
distance = 0.0,
|
||||
health = 20.0f,
|
||||
pose = "Standing",
|
||||
latency = 5
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
public MccMcpResult GetEntityInfo(int entityId, bool includeMetadata, bool includeEquipment, bool includeEffects) =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
id = entityId,
|
||||
type = "Player",
|
||||
typeLabel = "Player",
|
||||
uuid = Guid.Empty,
|
||||
name = "HarnessBot",
|
||||
customName = (string?)null,
|
||||
customNameVisible = false,
|
||||
x = C(0.5),
|
||||
y = C(80.0),
|
||||
z = C(0.5),
|
||||
yaw = 0.0f,
|
||||
pitch = 0.0f,
|
||||
health = 20.0f,
|
||||
pose = "Standing",
|
||||
latency = 5,
|
||||
objectData = -1,
|
||||
metadata = includeMetadata ? new { flags = 0 } : null,
|
||||
equipment = includeEquipment ? new[] { new { slot = 0, type = "Stone", count = 1 } } : null,
|
||||
activeEffects = includeEffects ? new object[0] : null
|
||||
});
|
||||
|
||||
public MccMcpResult FindSigns(string text, bool exactMatch, int radius, int maxCount, bool includeBackText) =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
text,
|
||||
exactMatch,
|
||||
radius,
|
||||
includeBackText,
|
||||
count = 1,
|
||||
signs = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
x = 2,
|
||||
y = 80,
|
||||
z = 1,
|
||||
material = "OakSign",
|
||||
typeLabel = "Oak Sign",
|
||||
distance = 1.8,
|
||||
isWaxed = false,
|
||||
frontText = new[] { "home", "storage" },
|
||||
backText = includeBackText ? new[] { "north wall" } : Array.Empty<string>(),
|
||||
matchedLines = new[] { text }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
public MccMcpResult ListItemEntities(string? itemType, double radius, int maxCount) =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
itemType = itemType ?? "OakLog",
|
||||
radius,
|
||||
count = 1,
|
||||
items = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
entityId = 99,
|
||||
itemType = "OakLog",
|
||||
typeLabel = "Oak Log",
|
||||
count = 3,
|
||||
x = C(2.5),
|
||||
y = C(80.0),
|
||||
z = C(1.5),
|
||||
distance = 2.24
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
public MccMcpResult PickupItems(string itemType, double radius, int maxItems, bool allowUnsafe, int timeoutMs) =>
|
||||
MccMcpResult.Ok(new
|
||||
{
|
||||
itemType,
|
||||
radius,
|
||||
maxItems,
|
||||
allowUnsafe,
|
||||
timeoutMs = timeoutMs <= 0 ? 2500 : timeoutMs,
|
||||
attempted = 1,
|
||||
successfulPickups = 1,
|
||||
collectedCount = 3,
|
||||
initialInventoryCount = 0,
|
||||
finalInventoryCount = 3,
|
||||
remainingNearby = 0,
|
||||
attempts = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
entityId = 99,
|
||||
itemType,
|
||||
typeLabel = "Oak Log",
|
||||
expectedCount = 3,
|
||||
target = new { x = C(2.5), y = C(80.0), z = C(1.5) },
|
||||
pathFound = true,
|
||||
arrived = true,
|
||||
entityGone = true,
|
||||
inventoryDelta = 3,
|
||||
startLocation = new { x = C(0.5), y = C(80.0), z = C(0.5) },
|
||||
finalLocation = new { x = C(2.5), y = C(80.0), z = C(1.5) },
|
||||
finalDistance = 0.0
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
public MccMcpResult Respawn()
|
||||
{
|
||||
health = 20.0f;
|
||||
AddRecentEvent("respawn", new { location = new { x = playerX, y = playerY, z = playerZ } });
|
||||
return MccMcpResult.Ok(new { success = true, respawned = true });
|
||||
}
|
||||
|
||||
public MccMcpResult GetWorldBlockAt(int x, int y, int z) =>
|
||||
MccMcpResult.Ok(new { x, y, z, material = "Air", blockId = 0, blockMeta = 0 });
|
||||
|
||||
private void AddRecentEvent(string type, object? data)
|
||||
{
|
||||
recentEvents.Add(new RecentEvent(nextEventId++, DateTimeOffset.UtcNow, type, data));
|
||||
if (recentEvents.Count > 100)
|
||||
recentEvents.RemoveAt(0);
|
||||
}
|
||||
|
||||
private sealed record RecentEvent(long Id, DateTimeOffset TimestampUtc, string Type, object? Data);
|
||||
}
|
||||
36
DebugTools/MccMcpWebPlayground/Api/MccPlaygroundEndpoints.cs
Normal file
36
DebugTools/MccMcpWebPlayground/Api/MccPlaygroundEndpoints.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
using DebugTools.MccMcpWebPlayground.Contracts;
|
||||
using DebugTools.MccMcpWebPlayground.Harness;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DebugTools.MccMcpWebPlayground.Api;
|
||||
|
||||
public static class MccPlaygroundEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapMccPlaygroundEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
RouteGroupBuilder api = endpoints.MapGroup("/api");
|
||||
|
||||
api.MapGet("/health", () => Results.Ok(new { ok = true }));
|
||||
|
||||
api.MapGet("/config", (IOptions<MccWebHarnessOptions> options) =>
|
||||
{
|
||||
MccWebHarnessOptions harnessOptions = options.Value;
|
||||
return Results.Ok(new MccConfigResponse(
|
||||
Model: harnessOptions.ResolveModel(),
|
||||
OpenRouterBaseUrl: harnessOptions.ResolveOpenRouterBaseUrl(),
|
||||
McpEndpoint: harnessOptions.ResolveMcpEndpoint(),
|
||||
HasApiKey: harnessOptions.HasApiKeyConfigured(),
|
||||
ExposeInventoryWindowAction: harnessOptions.ExposeInventoryWindowAction,
|
||||
ExposeInternalCommandTool: harnessOptions.ExposeInternalCommandTool));
|
||||
});
|
||||
|
||||
api.MapPost("/chat/stream", (ChatStreamRequest request, IMccAgentRunService runService, HttpContext httpContext, CancellationToken cancellationToken) =>
|
||||
{
|
||||
return TypedResults.ServerSentEvents(runService.StreamAsync(request, httpContext, cancellationToken));
|
||||
})
|
||||
.WithRequestTimeout("mcc-stream");
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
}
|
||||
94
DebugTools/MccMcpWebPlayground/Contracts/MccContracts.cs
Normal file
94
DebugTools/MccMcpWebPlayground/Contracts/MccContracts.cs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DebugTools.MccMcpWebPlayground.Contracts;
|
||||
|
||||
public sealed class ChatStreamRequest
|
||||
{
|
||||
public List<ChatMessage>? Messages { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ChatMessage
|
||||
{
|
||||
public string Role { get; set; } = string.Empty;
|
||||
public string Content { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed record MccConfigResponse(
|
||||
string? Model,
|
||||
string OpenRouterBaseUrl,
|
||||
string McpEndpoint,
|
||||
bool HasApiKey,
|
||||
bool ExposeInventoryWindowAction,
|
||||
bool ExposeInternalCommandTool);
|
||||
|
||||
public sealed record MccStreamEnvelope(string RunId, long Sequence, string Kind, object Data);
|
||||
|
||||
public sealed record MccRunStartedData(string Model, string McpEndpoint, DateTimeOffset StartedAtUtc);
|
||||
|
||||
public sealed record MccGuidanceLoadedData(
|
||||
string SourceTool,
|
||||
string CanonicalPromptName,
|
||||
string GuidanceVersion,
|
||||
MccCapabilityStatus CapabilityStatus);
|
||||
|
||||
public sealed record MccStateSummaryData(
|
||||
int TurnCount,
|
||||
int ToolCallCount,
|
||||
bool SoftFinish,
|
||||
int DirectAnswerAttempts,
|
||||
IReadOnlyList<MccVerificationObligationView> OpenVerification,
|
||||
IReadOnlyList<MccEvidenceView> RecentEvidence,
|
||||
string? CompactionSummary);
|
||||
|
||||
public sealed record MccToolCalledData(string CallId, string Name, string ArgumentsJson, bool Advanced, bool Sensitive);
|
||||
|
||||
public sealed record MccToolResultData(
|
||||
string CallId,
|
||||
string Name,
|
||||
bool IsError,
|
||||
bool Success,
|
||||
string? ErrorCode,
|
||||
string Summary,
|
||||
string RawText,
|
||||
string EvidenceId);
|
||||
|
||||
public sealed record MccVerificationEventData(string ObligationId, string ToolName, string Kind, string Description);
|
||||
|
||||
public sealed record MccBudgetData(
|
||||
int TurnCount,
|
||||
int MaxTurns,
|
||||
int ToolCallCount,
|
||||
int MaxToolCalls,
|
||||
double ElapsedSeconds,
|
||||
int MaxWallClockSeconds);
|
||||
|
||||
public sealed record MccErrorData(string Code, string Message, string? Detail = null);
|
||||
|
||||
public sealed record MccFinalPayload(
|
||||
string Status,
|
||||
string Headline,
|
||||
string AnswerMarkdown,
|
||||
IReadOnlyList<string> VerifiedFacts,
|
||||
IReadOnlyList<string> OpenIssues,
|
||||
IReadOnlyList<string> EvidenceIds,
|
||||
string? NextAction);
|
||||
|
||||
public sealed record MccSubmitFinalArgs(
|
||||
string Status,
|
||||
string Headline,
|
||||
string AnswerMarkdown,
|
||||
IReadOnlyList<string> VerifiedFacts,
|
||||
IReadOnlyList<string> OpenIssues,
|
||||
IReadOnlyList<string> EvidenceIds,
|
||||
string? NextAction);
|
||||
|
||||
public sealed record MccCapabilityStatus(
|
||||
[property: JsonPropertyName("sessionStatus")] bool SessionStatus,
|
||||
[property: JsonPropertyName("chatAndCommands")] bool ChatAndCommands,
|
||||
[property: JsonPropertyName("movement")] bool Movement,
|
||||
[property: JsonPropertyName("inventory")] bool Inventory,
|
||||
[property: JsonPropertyName("entityWorld")] bool EntityWorld);
|
||||
|
||||
public sealed record MccEvidenceView(string Id, string ToolName, string Summary, bool IsError);
|
||||
|
||||
public sealed record MccVerificationObligationView(string Id, string ToolName, string Kind, string Description);
|
||||
852
DebugTools/MccMcpWebPlayground/Harness/MccAgentRunService.cs
Normal file
852
DebugTools/MccMcpWebPlayground/Harness/MccAgentRunService.cs
Normal file
|
|
@ -0,0 +1,852 @@
|
|||
using System.Globalization;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Net.ServerSentEvents;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DebugTools.MccMcpWebPlayground.Contracts;
|
||||
using DebugTools.MccMcpWebPlayground.Infrastructure.Mcp;
|
||||
using DebugTools.MccMcpWebPlayground.Infrastructure.OpenRouter;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace DebugTools.MccMcpWebPlayground.Harness;
|
||||
|
||||
public interface IMccAgentRunService
|
||||
{
|
||||
IAsyncEnumerable<SseItem<MccStreamEnvelope>> StreamAsync(ChatStreamRequest request, HttpContext httpContext, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class MccAgentRunService : IMccAgentRunService
|
||||
{
|
||||
private readonly MccMcpSessionFactory sessionFactory;
|
||||
private readonly MccGuidanceSource guidanceSource;
|
||||
private readonly MccPromptComposer promptComposer;
|
||||
private readonly MccContextCompressor contextCompressor;
|
||||
private readonly MccFinalizer finalizer;
|
||||
private readonly OpenRouterChatClient openRouterChatClient;
|
||||
private readonly MccWebHarnessOptions options;
|
||||
|
||||
public MccAgentRunService(
|
||||
MccMcpSessionFactory sessionFactory,
|
||||
MccGuidanceSource guidanceSource,
|
||||
MccPromptComposer promptComposer,
|
||||
MccContextCompressor contextCompressor,
|
||||
MccFinalizer finalizer,
|
||||
OpenRouterChatClient openRouterChatClient,
|
||||
IOptions<MccWebHarnessOptions> options)
|
||||
{
|
||||
this.sessionFactory = sessionFactory;
|
||||
this.guidanceSource = guidanceSource;
|
||||
this.promptComposer = promptComposer;
|
||||
this.contextCompressor = contextCompressor;
|
||||
this.finalizer = finalizer;
|
||||
this.openRouterChatClient = openRouterChatClient;
|
||||
this.options = options.Value;
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<SseItem<MccStreamEnvelope>> StreamAsync(
|
||||
ChatStreamRequest request,
|
||||
HttpContext httpContext,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
using CancellationTokenSource linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, httpContext.RequestAborted);
|
||||
CancellationToken linkedToken = linkedCts.Token;
|
||||
|
||||
string runId = Guid.NewGuid().ToString("n");
|
||||
long sequence = 0;
|
||||
|
||||
string? model = options.ResolveModel();
|
||||
if (string.IsNullOrWhiteSpace(model))
|
||||
{
|
||||
yield return CreateEvent(runId, ref sequence, "error", new MccErrorData("configuration_error", "OPENROUTER_MODEL or MccWebHarness:Model must be configured."));
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (!options.HasApiKeyConfigured())
|
||||
{
|
||||
yield return CreateEvent(runId, ref sequence, "error", new MccErrorData("configuration_error", "OPENROUTER_API_KEY is not set."));
|
||||
yield break;
|
||||
}
|
||||
|
||||
List<object> baseConversationMessages = NormalizeConversation(request.Messages);
|
||||
string userRequest = ExtractUserRequest(request.Messages);
|
||||
if (string.IsNullOrWhiteSpace(userRequest))
|
||||
{
|
||||
yield return CreateEvent(runId, ref sequence, "error", new MccErrorData("invalid_request", "No user message was provided."));
|
||||
yield break;
|
||||
}
|
||||
|
||||
await using McpClient client = await sessionFactory.CreateAsync(linkedToken);
|
||||
MccGuidanceBundle guidance = await guidanceSource.LoadAsync(client, linkedToken);
|
||||
|
||||
MccRunState runState = new()
|
||||
{
|
||||
RunId = runId,
|
||||
UserRequest = userRequest,
|
||||
BaseConversationMessages = baseConversationMessages,
|
||||
ConfiguredModel = model,
|
||||
Guidance = guidance
|
||||
};
|
||||
|
||||
yield return CreateEvent(runId, ref sequence, "run_started", new MccRunStartedData(model, options.ResolveMcpEndpoint(), runState.StartedAtUtc));
|
||||
yield return CreateEvent(runId, ref sequence, "guidance_loaded", new MccGuidanceLoadedData(
|
||||
guidance.SourceToolName,
|
||||
guidance.CanonicalPromptName,
|
||||
guidance.GuidanceVersion,
|
||||
guidance.CapabilityStatus));
|
||||
|
||||
IList<McpClientTool> tools = await client.ListToolsAsync(cancellationToken: linkedToken);
|
||||
MccToolCatalog catalog = MccToolPolicy.BuildCatalog(tools, options, finalizer.BuildSubmitToolSchema());
|
||||
|
||||
while (!linkedToken.IsCancellationRequested)
|
||||
{
|
||||
runState.TurnCount++;
|
||||
contextCompressor.CompactIfNeeded(runState);
|
||||
yield return CreateEvent(runId, ref sequence, "state_summary", BuildStateSummary(runState, options));
|
||||
|
||||
if (runState.IsSoftFinish(options, DateTimeOffset.UtcNow))
|
||||
{
|
||||
yield return CreateEvent(runId, ref sequence, "budget", BuildBudgetData(runState));
|
||||
}
|
||||
|
||||
if (runState.IsHardStop(options, DateTimeOffset.UtcNow))
|
||||
break;
|
||||
|
||||
MccModelTurn? turn = null;
|
||||
Exception? providerException = null;
|
||||
try
|
||||
{
|
||||
turn = await openRouterChatClient.CreateTurnAsync(
|
||||
promptComposer.Compose(runState),
|
||||
catalog.ModelVisibleTools,
|
||||
options,
|
||||
linkedToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
providerException = ex;
|
||||
}
|
||||
|
||||
if (providerException is not null || turn is null)
|
||||
{
|
||||
yield return CreateEvent(runId, ref sequence, "error", new MccErrorData("provider_error", "OpenRouter request failed.", providerException?.Message));
|
||||
yield return CreateEvent(runId, ref sequence, "final", finalizer.BuildHardStopResult(runState, options));
|
||||
yield break;
|
||||
}
|
||||
|
||||
runState.RoutedModel = turn.ModelId;
|
||||
runState.RoutedProvider = turn.RoutedProvider;
|
||||
|
||||
if (turn.ToolCalls.Count == 0)
|
||||
{
|
||||
runState.DirectAnswerAttempts++;
|
||||
string content = string.IsNullOrWhiteSpace(turn.AssistantContent) ? "(empty assistant turn)" : turn.AssistantContent.Trim();
|
||||
runState.ToolConversationMessages.Add(new Dictionary<string, object?>
|
||||
{
|
||||
["role"] = "assistant",
|
||||
["content"] = content
|
||||
});
|
||||
|
||||
if (runState.DirectAnswerAttempts >= 4)
|
||||
{
|
||||
yield return CreateEvent(runId, ref sequence, "error", new MccErrorData(
|
||||
"model_protocol_error",
|
||||
"The model kept returning plain assistant text instead of using tools or mcc_submit_final.",
|
||||
content));
|
||||
yield return CreateEvent(runId, ref sequence, "final", finalizer.BuildHardStopResult(runState, options));
|
||||
yield break;
|
||||
}
|
||||
|
||||
runState.ToolConversationMessages.Add(new Dictionary<string, object?>
|
||||
{
|
||||
["role"] = "user",
|
||||
["content"] = "The previous plain assistant text was not accepted by this harness. On your next turn, you must either call the relevant MCC tools or call mcc_submit_final. Do not answer with plain assistant text again."
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
Dictionary<string, object?> assistantMessage = new()
|
||||
{
|
||||
["role"] = "assistant",
|
||||
["content"] = turn.AssistantContent,
|
||||
["tool_calls"] = turn.ToolCalls.Select(call => new Dictionary<string, object?>
|
||||
{
|
||||
["id"] = call.CallId,
|
||||
["type"] = "function",
|
||||
["function"] = new Dictionary<string, object?>
|
||||
{
|
||||
["name"] = call.Name,
|
||||
["arguments"] = call.ArgumentsJson
|
||||
}
|
||||
}).ToArray()
|
||||
};
|
||||
runState.ToolConversationMessages.Add(assistantMessage);
|
||||
|
||||
foreach (MccModelToolCall toolCall in turn.ToolCalls)
|
||||
{
|
||||
MccToolProfile profile = MccToolPolicy.GetProfile(toolCall.Name);
|
||||
yield return CreateEvent(runId, ref sequence, "tool_called", new MccToolCalledData(
|
||||
toolCall.CallId,
|
||||
toolCall.Name,
|
||||
toolCall.ArgumentsJson,
|
||||
profile.Risk == MccToolRisk.EscapeHatch,
|
||||
profile.Risk == MccToolRisk.Sensitive));
|
||||
|
||||
if (toolCall.Name.Equals("mcc_submit_final", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
MccFinalizationValidation validation = finalizer.Validate(runState, toolCall.ArgumentsJson);
|
||||
if (validation.Accepted)
|
||||
{
|
||||
yield return CreateEvent(runId, ref sequence, "final", validation.Payload!);
|
||||
yield break;
|
||||
}
|
||||
|
||||
string localResultText = JsonSerializer.Serialize(new
|
||||
{
|
||||
success = false,
|
||||
errorCode = "invalid_final_submission",
|
||||
message = validation.ErrorText
|
||||
});
|
||||
runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, localResultText));
|
||||
yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData(
|
||||
toolCall.CallId,
|
||||
toolCall.Name,
|
||||
IsError: true,
|
||||
Success: false,
|
||||
ErrorCode: "invalid_final_submission",
|
||||
Summary: validation.ErrorText ?? "Invalid final submission.",
|
||||
RawText: localResultText,
|
||||
EvidenceId: string.Empty));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (MccToolPolicy.RequiresExplicitUserIntent(toolCall.Name) && !MccToolPolicy.HasExplicitUserIntent(runState.UserRequest, toolCall.Name))
|
||||
{
|
||||
string localResultText = JsonSerializer.Serialize(new
|
||||
{
|
||||
success = false,
|
||||
errorCode = "explicit_user_intent_required",
|
||||
message = $"Tool '{toolCall.Name}' requires explicit user intent."
|
||||
});
|
||||
runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, localResultText));
|
||||
yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData(
|
||||
toolCall.CallId,
|
||||
toolCall.Name,
|
||||
IsError: true,
|
||||
Success: false,
|
||||
ErrorCode: "explicit_user_intent_required",
|
||||
Summary: $"Tool '{toolCall.Name}' requires explicit user intent.",
|
||||
RawText: localResultText,
|
||||
EvidenceId: string.Empty));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!catalog.ToolsByName.TryGetValue(toolCall.Name, out MccToolCatalogEntry? entry))
|
||||
{
|
||||
string unknownToolText = JsonSerializer.Serialize(new
|
||||
{
|
||||
success = false,
|
||||
errorCode = "unknown_tool",
|
||||
message = $"Unknown tool '{toolCall.Name}'."
|
||||
});
|
||||
runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, unknownToolText));
|
||||
yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData(
|
||||
toolCall.CallId,
|
||||
toolCall.Name,
|
||||
IsError: true,
|
||||
Success: false,
|
||||
ErrorCode: "unknown_tool",
|
||||
Summary: $"Unknown tool '{toolCall.Name}'.",
|
||||
RawText: unknownToolText,
|
||||
EvidenceId: string.Empty));
|
||||
continue;
|
||||
}
|
||||
|
||||
CallToolResult? result = null;
|
||||
Exception? toolException = null;
|
||||
try
|
||||
{
|
||||
Dictionary<string, object?> arguments = MccJsonArguments.Parse(toolCall.ArgumentsJson);
|
||||
result = await client.CallToolAsync(toolCall.Name, arguments, cancellationToken: linkedToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
toolException = ex;
|
||||
}
|
||||
|
||||
if (toolException is not null || result is null)
|
||||
{
|
||||
string failedText = JsonSerializer.Serialize(new
|
||||
{
|
||||
success = false,
|
||||
errorCode = "tool_call_failed",
|
||||
message = toolException?.Message
|
||||
});
|
||||
runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, failedText));
|
||||
yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData(
|
||||
toolCall.CallId,
|
||||
toolCall.Name,
|
||||
IsError: true,
|
||||
Success: false,
|
||||
ErrorCode: "tool_call_failed",
|
||||
Summary: toolException?.Message ?? "Tool call failed.",
|
||||
RawText: failedText,
|
||||
EvidenceId: string.Empty));
|
||||
continue;
|
||||
}
|
||||
|
||||
runState.ToolCallCount++;
|
||||
MccNormalizedToolResult normalized = MccMcpJson.Normalize(result);
|
||||
MccEvidenceRecord evidence = CreateEvidence(runState, toolCall.Name, normalized);
|
||||
runState.Evidence.Add(evidence);
|
||||
runState.ToolExecutions.Add(new MccToolExecutionRecord
|
||||
{
|
||||
CallId = toolCall.CallId,
|
||||
ToolName = toolCall.Name,
|
||||
ArgumentsJson = toolCall.ArgumentsJson,
|
||||
Evidence = evidence
|
||||
});
|
||||
|
||||
runState.ToolConversationMessages.Add(BuildToolMessage(toolCall.CallId, normalized.Text));
|
||||
|
||||
foreach (MccVerificationObligation obligation in CreateObligations(runState, evidence, toolCall.ArgumentsJson))
|
||||
{
|
||||
runState.VerificationObligations.Add(obligation);
|
||||
yield return CreateEvent(runId, ref sequence, "verification_required", new MccVerificationEventData(
|
||||
obligation.Id,
|
||||
obligation.ToolName,
|
||||
obligation.Kind,
|
||||
obligation.Description));
|
||||
|
||||
if (obligation.Cleared)
|
||||
{
|
||||
yield return CreateEvent(runId, ref sequence, "verification_cleared", new MccVerificationEventData(
|
||||
obligation.Id,
|
||||
obligation.ToolName,
|
||||
obligation.Kind,
|
||||
obligation.Description));
|
||||
}
|
||||
}
|
||||
|
||||
foreach (MccVerificationObligation cleared in TryClearObligationsFromEvidence(runState, evidence))
|
||||
{
|
||||
yield return CreateEvent(runId, ref sequence, "verification_cleared", new MccVerificationEventData(
|
||||
cleared.Id,
|
||||
cleared.ToolName,
|
||||
cleared.Kind,
|
||||
cleared.Description));
|
||||
}
|
||||
|
||||
yield return CreateEvent(runId, ref sequence, "tool_result", new MccToolResultData(
|
||||
toolCall.CallId,
|
||||
toolCall.Name,
|
||||
evidence.IsError,
|
||||
evidence.Success,
|
||||
evidence.ErrorCode,
|
||||
evidence.Summary,
|
||||
evidence.RawText,
|
||||
evidence.Id));
|
||||
}
|
||||
}
|
||||
|
||||
yield return CreateEvent(runId, ref sequence, "final", finalizer.BuildHardStopResult(runState, options));
|
||||
}
|
||||
|
||||
private static List<object> NormalizeConversation(List<ChatMessage>? incoming)
|
||||
{
|
||||
List<object> messages = [];
|
||||
if (incoming is null)
|
||||
return messages;
|
||||
|
||||
foreach (ChatMessage message in incoming)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message.Role) || string.IsNullOrWhiteSpace(message.Content))
|
||||
continue;
|
||||
|
||||
string role = message.Role.Trim().ToLowerInvariant();
|
||||
if (role is not ("user" or "assistant" or "system"))
|
||||
continue;
|
||||
|
||||
messages.Add(new Dictionary<string, object?>
|
||||
{
|
||||
["role"] = role,
|
||||
["content"] = message.Content.Trim()
|
||||
});
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
private static string ExtractUserRequest(List<ChatMessage>? incoming)
|
||||
{
|
||||
return incoming?
|
||||
.LastOrDefault(message => string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.IsNullOrWhiteSpace(message.Content))
|
||||
?.Content
|
||||
?.Trim()
|
||||
?? string.Empty;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> BuildToolMessage(string callId, string content)
|
||||
{
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["role"] = "tool",
|
||||
["tool_call_id"] = callId,
|
||||
["content"] = content
|
||||
};
|
||||
}
|
||||
|
||||
private static MccStateSummaryData BuildStateSummary(MccRunState runState, MccWebHarnessOptions options)
|
||||
{
|
||||
return new MccStateSummaryData(
|
||||
TurnCount: runState.TurnCount,
|
||||
ToolCallCount: runState.ToolCallCount,
|
||||
SoftFinish: runState.IsSoftFinish(options, DateTimeOffset.UtcNow),
|
||||
DirectAnswerAttempts: runState.DirectAnswerAttempts,
|
||||
OpenVerification: runState.OpenObligations
|
||||
.Select(obligation => new MccVerificationObligationView(obligation.Id, obligation.ToolName, obligation.Kind, obligation.Description))
|
||||
.ToArray(),
|
||||
RecentEvidence: runState.Evidence
|
||||
.TakeLast(6)
|
||||
.Select(evidence => new MccEvidenceView(evidence.Id, evidence.ToolName, evidence.Summary, evidence.IsError))
|
||||
.ToArray(),
|
||||
CompactionSummary: runState.CompactionSummary);
|
||||
}
|
||||
|
||||
private MccBudgetData BuildBudgetData(MccRunState runState)
|
||||
{
|
||||
return new MccBudgetData(
|
||||
TurnCount: runState.TurnCount,
|
||||
MaxTurns: options.MaxTurns,
|
||||
ToolCallCount: runState.ToolCallCount,
|
||||
MaxToolCalls: options.MaxToolCalls,
|
||||
ElapsedSeconds: (DateTimeOffset.UtcNow - runState.StartedAtUtc).TotalSeconds,
|
||||
MaxWallClockSeconds: options.MaxWallClockSeconds);
|
||||
}
|
||||
|
||||
private static MccEvidenceRecord CreateEvidence(MccRunState runState, string toolName, MccNormalizedToolResult result)
|
||||
{
|
||||
string summary = SummarizeEvidence(toolName, result);
|
||||
return new MccEvidenceRecord
|
||||
{
|
||||
Id = runState.NextEvidenceId(),
|
||||
ToolName = toolName,
|
||||
Summary = summary,
|
||||
RawText = result.Text,
|
||||
IsError = result.IsError,
|
||||
Success = result.Success,
|
||||
ErrorCode = result.ErrorCode,
|
||||
Root = result.Root,
|
||||
Data = result.Data
|
||||
};
|
||||
}
|
||||
|
||||
private static string SummarizeEvidence(string toolName, MccNormalizedToolResult result)
|
||||
{
|
||||
if (result.Data is JsonElement data)
|
||||
{
|
||||
if ((toolName.Equals("mcc_move_to", StringComparison.OrdinalIgnoreCase) || toolName.Equals("mcc_move_to_player", StringComparison.OrdinalIgnoreCase))
|
||||
&& TryReadBool(data, "arrived", out bool arrived))
|
||||
{
|
||||
return arrived
|
||||
? $"movement verified; arrived={arrived}"
|
||||
: $"movement not yet verified; arrived={arrived}";
|
||||
}
|
||||
|
||||
if (toolName.Equals("mcc_dig_block", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
bool destroyed = TryReadBool(data, "destroyed", out bool destroyedValue) && destroyedValue;
|
||||
bool changed = TryReadBool(data, "changed", out bool changedValue) && changedValue;
|
||||
return $"dig result changed={changed} destroyed={destroyed}";
|
||||
}
|
||||
|
||||
if (toolName.Equals("mcc_items_pickup", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
int successful = TryReadInt(data, "successfulPickups", out int successfulValue) ? successfulValue : 0;
|
||||
int collected = TryReadInt(data, "collectedCount", out int collectedValue) ? collectedValue : 0;
|
||||
return $"pickup result successfulPickups={successful} collectedCount={collected}";
|
||||
}
|
||||
|
||||
if (toolName.Equals("mcc_container_open_at", StringComparison.OrdinalIgnoreCase)
|
||||
&& TryReadBool(data, "opened", out bool opened))
|
||||
{
|
||||
return $"container open result opened={opened}";
|
||||
}
|
||||
|
||||
if (toolName is "mcc_container_deposit_item" or "mcc_container_withdraw_item" or "mcc_inventory_drop_item")
|
||||
{
|
||||
int moved = TryReadInt(data, "movedCount", out int movedValue)
|
||||
? movedValue
|
||||
: TryReadInt(data, "droppedCount", out int droppedValue) ? droppedValue : 0;
|
||||
return $"{toolName} movedCount={moved}";
|
||||
}
|
||||
}
|
||||
|
||||
string prefix = result.IsError ? "error" : "ok";
|
||||
return $"{prefix}: {Truncate(result.Text.Replace('\n', ' '), 180)}";
|
||||
}
|
||||
|
||||
private List<MccVerificationObligation> CreateObligations(MccRunState runState, MccEvidenceRecord evidence, string argumentsJson)
|
||||
{
|
||||
List<MccVerificationObligation> obligations = [];
|
||||
JsonElement metadata = ParseArgumentsToJson(argumentsJson);
|
||||
|
||||
if (evidence.ToolName.Equals("mcc_move_to", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
MccVerificationObligation obligation = new()
|
||||
{
|
||||
Id = runState.NextObligationId(),
|
||||
ToolName = evidence.ToolName,
|
||||
Kind = "movement",
|
||||
Description = "Verify final player location for the requested move target.",
|
||||
SourceEvidenceId = evidence.Id,
|
||||
Metadata = BuildMoveMetadata(evidence, metadata),
|
||||
Cleared = IsMovementVerified(evidence)
|
||||
};
|
||||
obligations.Add(obligation);
|
||||
return obligations;
|
||||
}
|
||||
|
||||
if (evidence.ToolName.Equals("mcc_move_to_player", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
MccVerificationObligation obligation = new()
|
||||
{
|
||||
Id = runState.NextObligationId(),
|
||||
ToolName = evidence.ToolName,
|
||||
Kind = "movement",
|
||||
Description = "Verify final proximity to the requested player target.",
|
||||
SourceEvidenceId = evidence.Id,
|
||||
Metadata = BuildMoveToPlayerMetadata(evidence, metadata),
|
||||
Cleared = IsMovementVerified(evidence)
|
||||
};
|
||||
obligations.Add(obligation);
|
||||
return obligations;
|
||||
}
|
||||
|
||||
if (evidence.ToolName.Equals("mcc_container_open_at", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
obligations.Add(new MccVerificationObligation
|
||||
{
|
||||
Id = runState.NextObligationId(),
|
||||
ToolName = evidence.ToolName,
|
||||
Kind = "container",
|
||||
Description = "Verify that the target container is open and active.",
|
||||
SourceEvidenceId = evidence.Id,
|
||||
Metadata = null,
|
||||
Cleared = IsContainerOpenVerified(evidence)
|
||||
});
|
||||
return obligations;
|
||||
}
|
||||
|
||||
if (evidence.ToolName is "mcc_container_deposit_item" or "mcc_container_withdraw_item" or "mcc_inventory_drop_item")
|
||||
{
|
||||
obligations.Add(new MccVerificationObligation
|
||||
{
|
||||
Id = runState.NextObligationId(),
|
||||
ToolName = evidence.ToolName,
|
||||
Kind = "inventory",
|
||||
Description = "Verify the requested inventory delta.",
|
||||
SourceEvidenceId = evidence.Id,
|
||||
Metadata = evidence.Data,
|
||||
Cleared = IsInventoryVerified(evidence)
|
||||
});
|
||||
return obligations;
|
||||
}
|
||||
|
||||
if (evidence.ToolName.Equals("mcc_items_pickup", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
obligations.Add(new MccVerificationObligation
|
||||
{
|
||||
Id = runState.NextObligationId(),
|
||||
ToolName = evidence.ToolName,
|
||||
Kind = "pickup",
|
||||
Description = "Verify that the requested dropped items were picked up.",
|
||||
SourceEvidenceId = evidence.Id,
|
||||
Metadata = evidence.Data,
|
||||
Cleared = IsPickupVerified(evidence)
|
||||
});
|
||||
return obligations;
|
||||
}
|
||||
|
||||
if (evidence.ToolName.Equals("mcc_dig_block", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
obligations.Add(new MccVerificationObligation
|
||||
{
|
||||
Id = runState.NextObligationId(),
|
||||
ToolName = evidence.ToolName,
|
||||
Kind = "block_change",
|
||||
Description = "Verify that the target block changed state after digging.",
|
||||
SourceEvidenceId = evidence.Id,
|
||||
Metadata = evidence.Data,
|
||||
Cleared = IsDigVerified(evidence)
|
||||
});
|
||||
}
|
||||
|
||||
return obligations;
|
||||
}
|
||||
|
||||
private List<MccVerificationObligation> TryClearObligationsFromEvidence(MccRunState runState, MccEvidenceRecord evidence)
|
||||
{
|
||||
List<MccVerificationObligation> cleared = [];
|
||||
foreach (MccVerificationObligation obligation in runState.OpenObligations)
|
||||
{
|
||||
if (obligation.Cleared)
|
||||
continue;
|
||||
|
||||
if (obligation.Kind == "movement" && TryClearMovementObligation(obligation, evidence))
|
||||
{
|
||||
obligation.Cleared = true;
|
||||
obligation.ClearedByEvidenceId = evidence.Id;
|
||||
cleared.Add(obligation);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (obligation.Kind == "block_change" && TryClearDigObligation(obligation, evidence))
|
||||
{
|
||||
obligation.Cleared = true;
|
||||
obligation.ClearedByEvidenceId = evidence.Id;
|
||||
cleared.Add(obligation);
|
||||
}
|
||||
}
|
||||
|
||||
return cleared;
|
||||
}
|
||||
|
||||
private static bool TryClearMovementObligation(MccVerificationObligation obligation, MccEvidenceRecord evidence)
|
||||
{
|
||||
if (evidence.ToolName.Equals("mcc_player_state", StringComparison.OrdinalIgnoreCase)
|
||||
&& evidence.Data is JsonElement data
|
||||
&& data.TryGetProperty("location", out JsonElement location)
|
||||
&& obligation.Metadata is JsonElement metadata)
|
||||
{
|
||||
if (obligation.ToolName.Equals("mcc_move_to", StringComparison.OrdinalIgnoreCase)
|
||||
&& metadata.TryGetProperty("x", out JsonElement targetX)
|
||||
&& metadata.TryGetProperty("y", out JsonElement targetY)
|
||||
&& metadata.TryGetProperty("z", out JsonElement targetZ))
|
||||
{
|
||||
double tolerance = metadata.TryGetProperty("tolerance", out JsonElement toleranceElement) && toleranceElement.TryGetDouble(out double tol) ? tol : 1.5;
|
||||
return TryReadDouble(location, "x", out double x)
|
||||
&& TryReadDouble(location, "y", out double y)
|
||||
&& TryReadDouble(location, "z", out double z)
|
||||
&& Distance(x, y, z, targetX.GetDouble(), targetY.GetDouble(), targetZ.GetDouble()) <= tolerance;
|
||||
}
|
||||
}
|
||||
|
||||
if (evidence.ToolName.Equals("mcc_player_locate", StringComparison.OrdinalIgnoreCase)
|
||||
&& obligation.ToolName.Equals("mcc_move_to_player", StringComparison.OrdinalIgnoreCase)
|
||||
&& evidence.Data is JsonElement playerData
|
||||
&& obligation.Metadata is JsonElement playerMetadata)
|
||||
{
|
||||
string? expectedName = playerMetadata.TryGetProperty("playerName", out JsonElement nameElement) ? nameElement.GetString() : null;
|
||||
string? matchedName = playerData.TryGetProperty("matchedName", out JsonElement matchedNameElement) ? matchedNameElement.GetString() : null;
|
||||
if (!string.IsNullOrWhiteSpace(expectedName) && !string.Equals(expectedName, matchedName, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
if (TryReadDouble(playerData, "distance", out double distance))
|
||||
{
|
||||
double tolerance = playerMetadata.TryGetProperty("tolerance", out JsonElement toleranceElement) && toleranceElement.TryGetDouble(out double tol) ? tol : 2.0;
|
||||
return distance <= tolerance;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryClearDigObligation(MccVerificationObligation obligation, MccEvidenceRecord evidence)
|
||||
{
|
||||
if (!evidence.ToolName.Equals("mcc_world_block_at", StringComparison.OrdinalIgnoreCase)
|
||||
|| evidence.Data is not JsonElement data
|
||||
|| obligation.Metadata is not JsonElement metadata)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!metadata.TryGetProperty("target", out JsonElement target)
|
||||
|| !TryReadDouble(target, "x", out double x)
|
||||
|| !TryReadDouble(target, "y", out double y)
|
||||
|| !TryReadDouble(target, "z", out double z))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryReadInt(data, "x", out int blockX)
|
||||
&& TryReadInt(data, "y", out int blockY)
|
||||
&& TryReadInt(data, "z", out int blockZ)
|
||||
&& Math.Abs(blockX - x) < 0.5
|
||||
&& Math.Abs(blockY - y) < 0.5
|
||||
&& Math.Abs(blockZ - z) < 0.5
|
||||
&& data.TryGetProperty("block", out JsonElement block)
|
||||
&& block.TryGetProperty("material", out JsonElement material)
|
||||
&& !string.Equals(material.GetString(), "Air", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static bool IsMovementVerified(MccEvidenceRecord evidence)
|
||||
{
|
||||
if (evidence.Data is not JsonElement data)
|
||||
return false;
|
||||
|
||||
if (TryReadBool(data, "arrived", out bool arrived) && arrived)
|
||||
return true;
|
||||
|
||||
if (TryReadDouble(data, "finalDistance", out double finalDistance))
|
||||
{
|
||||
double tolerance = TryReadDouble(data, "tolerance", out double tol) ? tol : 1.5;
|
||||
return finalDistance <= tolerance;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsContainerOpenVerified(MccEvidenceRecord evidence)
|
||||
{
|
||||
return evidence.Data is JsonElement data
|
||||
&& TryReadBool(data, "opened", out bool opened)
|
||||
&& opened;
|
||||
}
|
||||
|
||||
private static bool IsInventoryVerified(MccEvidenceRecord evidence)
|
||||
{
|
||||
if (evidence.Data is not JsonElement data)
|
||||
return false;
|
||||
|
||||
if (TryReadInt(data, "requestedCount", out int requestedCount)
|
||||
&& TryReadInt(data, "movedCount", out int movedCount))
|
||||
{
|
||||
return movedCount == requestedCount;
|
||||
}
|
||||
|
||||
if (TryReadInt(data, "requestedCount", out requestedCount)
|
||||
&& TryReadInt(data, "droppedCount", out int droppedCount))
|
||||
{
|
||||
return droppedCount == requestedCount;
|
||||
}
|
||||
|
||||
return evidence.Success;
|
||||
}
|
||||
|
||||
private static bool IsPickupVerified(MccEvidenceRecord evidence)
|
||||
{
|
||||
if (evidence.Data is not JsonElement data)
|
||||
return false;
|
||||
|
||||
return (TryReadInt(data, "successfulPickups", out int successfulPickups) && successfulPickups > 0)
|
||||
|| (TryReadInt(data, "collectedCount", out int collectedCount) && collectedCount > 0);
|
||||
}
|
||||
|
||||
private static bool IsDigVerified(MccEvidenceRecord evidence)
|
||||
{
|
||||
if (evidence.Data is not JsonElement data)
|
||||
return false;
|
||||
|
||||
return (TryReadBool(data, "destroyed", out bool destroyed) && destroyed)
|
||||
|| (TryReadBool(data, "changed", out bool changed) && changed);
|
||||
}
|
||||
|
||||
private static JsonElement? BuildMoveMetadata(MccEvidenceRecord evidence, JsonElement arguments)
|
||||
{
|
||||
if (evidence.Data is not JsonElement data)
|
||||
return null;
|
||||
|
||||
double x = TryReadDoubleFromArguments(arguments, "x", out double targetX)
|
||||
? targetX
|
||||
: data.TryGetProperty("target", out JsonElement target) && TryReadDouble(target, "x", out double fromDataX) ? fromDataX : 0;
|
||||
double y = TryReadDoubleFromArguments(arguments, "y", out double targetY)
|
||||
? targetY
|
||||
: data.TryGetProperty("target", out target) && TryReadDouble(target, "y", out double fromDataY) ? fromDataY : 0;
|
||||
double z = TryReadDoubleFromArguments(arguments, "z", out double targetZ)
|
||||
? targetZ
|
||||
: data.TryGetProperty("target", out target) && TryReadDouble(target, "z", out double fromDataZ) ? fromDataZ : 0;
|
||||
double tolerance = TryReadDouble(data, "tolerance", out double tol) ? tol : 1.5;
|
||||
|
||||
return JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
tolerance
|
||||
});
|
||||
}
|
||||
|
||||
private static JsonElement? BuildMoveToPlayerMetadata(MccEvidenceRecord evidence, JsonElement arguments)
|
||||
{
|
||||
string? playerName = arguments.TryGetProperty("playerName", out JsonElement property) ? property.GetString() : null;
|
||||
double tolerance = evidence.Data is JsonElement data && TryReadDouble(data, "tolerance", out double tol) ? tol : 2.0;
|
||||
return JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
playerName,
|
||||
tolerance
|
||||
});
|
||||
}
|
||||
|
||||
private static JsonElement ParseArgumentsToJson(string argumentsJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
catch
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse("{}");
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryReadBool(JsonElement element, string propertyName, out bool value)
|
||||
{
|
||||
value = false;
|
||||
return element.TryGetProperty(propertyName, out JsonElement property)
|
||||
&& property.ValueKind is JsonValueKind.True or JsonValueKind.False
|
||||
&& ((value = property.GetBoolean()) || !value || true);
|
||||
}
|
||||
|
||||
private static bool TryReadInt(JsonElement element, string propertyName, out int value)
|
||||
{
|
||||
value = 0;
|
||||
return element.TryGetProperty(propertyName, out JsonElement property) && property.TryGetInt32(out value);
|
||||
}
|
||||
|
||||
private static bool TryReadDouble(JsonElement element, string propertyName, out double value)
|
||||
{
|
||||
value = 0;
|
||||
return element.TryGetProperty(propertyName, out JsonElement property) && property.TryGetDouble(out value);
|
||||
}
|
||||
|
||||
private static bool TryReadDoubleFromArguments(JsonElement element, string propertyName, out double value)
|
||||
{
|
||||
value = 0;
|
||||
if (!element.TryGetProperty(propertyName, out JsonElement property))
|
||||
return false;
|
||||
|
||||
return property.ValueKind == JsonValueKind.Number
|
||||
? property.TryGetDouble(out value)
|
||||
: property.ValueKind == JsonValueKind.String && double.TryParse(property.GetString(), out value);
|
||||
}
|
||||
|
||||
private static double Distance(double x1, double y1, double z1, double x2, double y2, double z2)
|
||||
{
|
||||
double dx = x1 - x2;
|
||||
double dy = y1 - y2;
|
||||
double dz = z1 - z2;
|
||||
return Math.Sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
private static string Truncate(string text, int maxLength)
|
||||
{
|
||||
return string.IsNullOrEmpty(text) || text.Length <= maxLength ? text : text[..maxLength] + "...";
|
||||
}
|
||||
|
||||
private static SseItem<MccStreamEnvelope> CreateEvent<T>(string runId, ref long sequence, string kind, T data)
|
||||
{
|
||||
sequence++;
|
||||
return new SseItem<MccStreamEnvelope>(
|
||||
new MccStreamEnvelope(runId, sequence, kind, data!),
|
||||
kind)
|
||||
{
|
||||
EventId = sequence.ToString(CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
namespace DebugTools.MccMcpWebPlayground.Harness;
|
||||
|
||||
public sealed class MccContextCompressor
|
||||
{
|
||||
public void CompactIfNeeded(MccRunState runState)
|
||||
{
|
||||
if (runState.Evidence.Count <= 6)
|
||||
return;
|
||||
|
||||
IReadOnlyList<MccEvidenceRecord> olderEvidence = runState.Evidence
|
||||
.Take(Math.Max(0, runState.Evidence.Count - 6))
|
||||
.ToArray();
|
||||
|
||||
if (olderEvidence.Count == 0)
|
||||
return;
|
||||
|
||||
runState.CompactionSummary = string.Join('\n', olderEvidence
|
||||
.TakeLast(8)
|
||||
.Select(record => $"- {record.Id} {record.ToolName}: {record.Summary}"));
|
||||
}
|
||||
}
|
||||
221
DebugTools/MccMcpWebPlayground/Harness/MccFinalizer.cs
Normal file
221
DebugTools/MccMcpWebPlayground/Harness/MccFinalizer.cs
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using DebugTools.MccMcpWebPlayground.Contracts;
|
||||
|
||||
namespace DebugTools.MccMcpWebPlayground.Harness;
|
||||
|
||||
public sealed class MccFinalizer
|
||||
{
|
||||
private static readonly string[] AllowedStatuses = ["completed", "partial", "blocked", "clarification_needed", "failed"];
|
||||
|
||||
public object BuildSubmitToolSchema()
|
||||
{
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["type"] = "function",
|
||||
["function"] = new Dictionary<string, object?>
|
||||
{
|
||||
["name"] = "mcc_submit_final",
|
||||
["description"] = "Submit the final result for this MCC run. Use completed only when no required verification obligations remain open.",
|
||||
["parameters"] = new JsonObject
|
||||
{
|
||||
["type"] = "object",
|
||||
["additionalProperties"] = false,
|
||||
["properties"] = new JsonObject
|
||||
{
|
||||
["status"] = new JsonObject
|
||||
{
|
||||
["type"] = "string",
|
||||
["enum"] = new JsonArray(AllowedStatuses.Select(status => JsonValue.Create(status)).ToArray())
|
||||
},
|
||||
["headline"] = new JsonObject { ["type"] = "string" },
|
||||
["answerMarkdown"] = new JsonObject { ["type"] = "string" },
|
||||
["verifiedFacts"] = new JsonObject
|
||||
{
|
||||
["type"] = "array",
|
||||
["items"] = new JsonObject { ["type"] = "string" }
|
||||
},
|
||||
["openIssues"] = new JsonObject
|
||||
{
|
||||
["type"] = "array",
|
||||
["items"] = new JsonObject { ["type"] = "string" }
|
||||
},
|
||||
["evidenceIds"] = new JsonObject
|
||||
{
|
||||
["type"] = "array",
|
||||
["items"] = new JsonObject { ["type"] = "string" }
|
||||
},
|
||||
["nextAction"] = new JsonObject
|
||||
{
|
||||
["type"] = new JsonArray("string", "null")
|
||||
}
|
||||
},
|
||||
["required"] = new JsonArray("status", "headline", "answerMarkdown", "verifiedFacts", "openIssues", "evidenceIds", "nextAction")
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public MccFinalizationValidation Validate(MccRunState runState, string argumentsJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(string.IsNullOrWhiteSpace(argumentsJson) ? "{}" : argumentsJson);
|
||||
JsonElement root = document.RootElement;
|
||||
MccSubmitFinalArgs submission = new(
|
||||
Status: ReadRequiredString(root, "status"),
|
||||
Headline: ReadRequiredString(root, "headline"),
|
||||
AnswerMarkdown: ReadRequiredString(root, "answerMarkdown"),
|
||||
VerifiedFacts: ReadStringArray(root, "verifiedFacts"),
|
||||
OpenIssues: ReadStringArray(root, "openIssues"),
|
||||
EvidenceIds: ReadStringArray(root, "evidenceIds"),
|
||||
NextAction: ReadNullableString(root, "nextAction"));
|
||||
|
||||
string normalizedStatus = submission.Status.Trim().ToLowerInvariant();
|
||||
if (!AllowedStatuses.Contains(normalizedStatus, StringComparer.Ordinal))
|
||||
return MccFinalizationValidation.Reject("Invalid final status.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(submission.Headline) || string.IsNullOrWhiteSpace(submission.AnswerMarkdown))
|
||||
return MccFinalizationValidation.Reject("headline and answerMarkdown are required.");
|
||||
|
||||
Dictionary<string, MccEvidenceRecord> evidenceById = runState.Evidence.ToDictionary(record => record.Id, StringComparer.OrdinalIgnoreCase);
|
||||
Dictionary<string, string> evidenceAliasByCallId = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (MccToolExecutionRecord execution in runState.ToolExecutions)
|
||||
{
|
||||
evidenceAliasByCallId[execution.CallId] = execution.Evidence.Id;
|
||||
|
||||
int suffixSeparator = execution.CallId.LastIndexOf('_');
|
||||
if (suffixSeparator >= 0 && suffixSeparator < execution.CallId.Length - 1)
|
||||
evidenceAliasByCallId[execution.CallId[(suffixSeparator + 1)..]] = execution.Evidence.Id;
|
||||
}
|
||||
|
||||
List<string> normalizedEvidenceIds = [];
|
||||
foreach (string evidenceId in submission.EvidenceIds)
|
||||
{
|
||||
string normalizedEvidenceId = evidenceAliasByCallId.TryGetValue(evidenceId, out string? mappedEvidenceId)
|
||||
? mappedEvidenceId
|
||||
: evidenceId;
|
||||
|
||||
if (!evidenceById.ContainsKey(normalizedEvidenceId))
|
||||
return MccFinalizationValidation.Reject($"Unknown evidence id '{evidenceId}'.");
|
||||
|
||||
if (!normalizedEvidenceIds.Contains(normalizedEvidenceId, StringComparer.OrdinalIgnoreCase))
|
||||
normalizedEvidenceIds.Add(normalizedEvidenceId);
|
||||
}
|
||||
|
||||
if (normalizedStatus == "completed" && runState.OpenObligations.Count > 0)
|
||||
return MccFinalizationValidation.Reject("completed is invalid while verification obligations remain open.");
|
||||
|
||||
if (!AreVerifiedFactsGrounded(submission.VerifiedFacts, normalizedEvidenceIds, evidenceById))
|
||||
return MccFinalizationValidation.Reject("verifiedFacts must be grounded in the referenced evidence.");
|
||||
|
||||
return MccFinalizationValidation.Accept(new MccFinalPayload(
|
||||
normalizedStatus,
|
||||
submission.Headline.Trim(),
|
||||
submission.AnswerMarkdown.Trim(),
|
||||
submission.VerifiedFacts,
|
||||
submission.OpenIssues,
|
||||
normalizedEvidenceIds,
|
||||
string.IsNullOrWhiteSpace(submission.NextAction) ? null : submission.NextAction.Trim()));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return MccFinalizationValidation.Reject($"Invalid mcc_submit_final payload: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public MccFinalPayload BuildHardStopResult(MccRunState runState, MccWebHarnessOptions options)
|
||||
{
|
||||
IReadOnlyList<string> openIssues = runState.OpenObligations.Count > 0
|
||||
? runState.OpenObligations.Select(obligation => obligation.Description).ToArray()
|
||||
: ["The harness reached its execution budget before the run was explicitly finalized."];
|
||||
|
||||
IReadOnlyList<string> evidenceIds = runState.Evidence.TakeLast(4).Select(record => record.Id).ToArray();
|
||||
IReadOnlyList<string> verifiedFacts = runState.Evidence
|
||||
.TakeLast(4)
|
||||
.Where(record => record.Success)
|
||||
.Select(record => record.Summary)
|
||||
.ToArray();
|
||||
|
||||
return new MccFinalPayload(
|
||||
Status: runState.OpenObligations.Count > 0 ? "partial" : "blocked",
|
||||
Headline: "Run stopped before explicit completion",
|
||||
AnswerMarkdown: "I could not finish the request within the current harness budget. I am returning the strongest verified state captured so far.",
|
||||
VerifiedFacts: verifiedFacts,
|
||||
OpenIssues: openIssues,
|
||||
EvidenceIds: evidenceIds,
|
||||
NextAction: "Retry with a fresh run if you want me to continue from the latest verified state.");
|
||||
}
|
||||
|
||||
private static bool AreVerifiedFactsGrounded(
|
||||
IReadOnlyList<string> verifiedFacts,
|
||||
IReadOnlyList<string> evidenceIds,
|
||||
IReadOnlyDictionary<string, MccEvidenceRecord> evidenceById)
|
||||
{
|
||||
if (verifiedFacts.Count == 0)
|
||||
return true;
|
||||
|
||||
if (evidenceIds.Count == 0)
|
||||
return false;
|
||||
|
||||
string evidenceCorpus = string.Join(' ', evidenceIds
|
||||
.Where(evidenceById.ContainsKey)
|
||||
.Select(id => evidenceById[id].Summary))
|
||||
.ToLowerInvariant();
|
||||
|
||||
foreach (string fact in verifiedFacts)
|
||||
{
|
||||
HashSet<string> factTokens = fact.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(token => token.Trim().Trim(',', '.', ':', ';', '!', '?', '"', '\''))
|
||||
.Where(token => token.Length >= 4)
|
||||
.Select(token => token.ToLowerInvariant())
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
if (factTokens.Count == 0)
|
||||
continue;
|
||||
|
||||
int matches = factTokens.Count(token => evidenceCorpus.Contains(token, StringComparison.Ordinal));
|
||||
if (matches < Math.Min(2, factTokens.Count))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string ReadRequiredString(JsonElement root, string propertyName)
|
||||
{
|
||||
string? value = ReadNullableString(root, propertyName);
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
throw new InvalidOperationException($"{propertyName} is required.");
|
||||
|
||||
return value.Trim();
|
||||
}
|
||||
|
||||
private static string? ReadNullableString(JsonElement root, string propertyName)
|
||||
{
|
||||
if (!root.TryGetProperty(propertyName, out JsonElement property))
|
||||
return null;
|
||||
|
||||
return property.ValueKind == JsonValueKind.Null ? null : property.GetString();
|
||||
}
|
||||
|
||||
private static string[] ReadStringArray(JsonElement root, string propertyName)
|
||||
{
|
||||
if (!root.TryGetProperty(propertyName, out JsonElement property) || property.ValueKind != JsonValueKind.Array)
|
||||
return [];
|
||||
|
||||
return property.EnumerateArray()
|
||||
.Where(item => item.ValueKind == JsonValueKind.String)
|
||||
.Select(item => item.GetString())
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Cast<string>()
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record MccFinalizationValidation(bool Accepted, string? ErrorText, MccFinalPayload? Payload)
|
||||
{
|
||||
public static MccFinalizationValidation Accept(MccFinalPayload payload) => new(true, null, payload);
|
||||
|
||||
public static MccFinalizationValidation Reject(string errorText) => new(false, errorText, null);
|
||||
}
|
||||
63
DebugTools/MccMcpWebPlayground/Harness/MccGuidanceSource.cs
Normal file
63
DebugTools/MccMcpWebPlayground/Harness/MccGuidanceSource.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
using System.Text.Json;
|
||||
using DebugTools.MccMcpWebPlayground.Contracts;
|
||||
using DebugTools.MccMcpWebPlayground.Infrastructure.Mcp;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace DebugTools.MccMcpWebPlayground.Harness;
|
||||
|
||||
public sealed class MccGuidanceSource
|
||||
{
|
||||
public const string SourceToolName = "mcc_agent_guidance";
|
||||
public const string CanonicalPromptName = "mcc_operator_guide";
|
||||
|
||||
public async Task<MccGuidanceBundle> LoadAsync(McpClient client, CancellationToken cancellationToken)
|
||||
{
|
||||
CallToolResult result = await client.CallToolAsync(SourceToolName, new Dictionary<string, object?>(), cancellationToken: cancellationToken);
|
||||
MccNormalizedToolResult normalized = MccMcpJson.Normalize(result);
|
||||
JsonElement data = normalized.Data ?? throw new InvalidOperationException("mcc_agent_guidance did not return data.");
|
||||
|
||||
string[] bestPractices = ReadStringArray(data, "bestPractices");
|
||||
string[] exampleTitles = data.TryGetProperty("exampleScenarios", out JsonElement examples)
|
||||
&& examples.ValueKind == JsonValueKind.Array
|
||||
? examples.EnumerateArray()
|
||||
.Select(example => example.TryGetProperty("title", out JsonElement title) ? title.GetString() : null)
|
||||
.Where(title => !string.IsNullOrWhiteSpace(title))
|
||||
.Cast<string>()
|
||||
.ToArray()
|
||||
: [];
|
||||
|
||||
MccCapabilityStatus capabilityStatus = data.TryGetProperty("capabilityStatus", out JsonElement capabilityJson)
|
||||
? JsonSerializer.Deserialize<MccCapabilityStatus>(capabilityJson.GetRawText()) ?? new MccCapabilityStatus(false, false, false, false, false)
|
||||
: new MccCapabilityStatus(false, false, false, false, false);
|
||||
|
||||
return new MccGuidanceBundle(
|
||||
SourceToolName,
|
||||
CanonicalPromptName,
|
||||
SkillName: ReadString(data, "skillName") ?? "mcc-mcp-operator",
|
||||
GuidanceVersion: ReadString(data, "guidanceVersion") ?? "unknown",
|
||||
SystemPrompt: ReadString(data, "systemPrompt") ?? throw new InvalidOperationException("mcc_agent_guidance did not return systemPrompt."),
|
||||
BestPractices: bestPractices,
|
||||
ExampleScenarioTitles: exampleTitles,
|
||||
CapabilityStatus: capabilityStatus);
|
||||
}
|
||||
|
||||
private static string? ReadString(JsonElement element, string propertyName)
|
||||
{
|
||||
return element.TryGetProperty(propertyName, out JsonElement property) && property.ValueKind == JsonValueKind.String
|
||||
? property.GetString()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string[] ReadStringArray(JsonElement element, string propertyName)
|
||||
{
|
||||
return element.TryGetProperty(propertyName, out JsonElement property) && property.ValueKind == JsonValueKind.Array
|
||||
? property.EnumerateArray()
|
||||
.Where(item => item.ValueKind == JsonValueKind.String)
|
||||
.Select(item => item.GetString())
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.Cast<string>()
|
||||
.ToArray()
|
||||
: [];
|
||||
}
|
||||
}
|
||||
86
DebugTools/MccMcpWebPlayground/Harness/MccPromptComposer.cs
Normal file
86
DebugTools/MccMcpWebPlayground/Harness/MccPromptComposer.cs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
namespace DebugTools.MccMcpWebPlayground.Harness;
|
||||
|
||||
public sealed class MccPromptComposer
|
||||
{
|
||||
private const string HarnessContract = """
|
||||
You are operating Minecraft Console Client through MCC MCP tools.
|
||||
|
||||
Rules:
|
||||
- Use tool results and the run-state summary as the source of truth.
|
||||
- Execute tools sequentially.
|
||||
- End the run only with mcc_submit_final.
|
||||
- status=completed is valid only when no required verification obligations remain open.
|
||||
- If the task is blocked or partial, say exactly what is verified and what remains unverified.
|
||||
- Do not repeat the same failing stateful action with the same arguments.
|
||||
- mcc_quit_client requires explicit user intent.
|
||||
- Prefer structured high-level tools. Avoid escape hatches unless they are explicitly exposed and necessary.
|
||||
""";
|
||||
|
||||
public List<object> Compose(MccRunState runState)
|
||||
{
|
||||
List<object> messages =
|
||||
[
|
||||
BuildSystemMessage(HarnessContract),
|
||||
BuildSystemMessage(runState.Guidance.SystemPrompt),
|
||||
BuildSystemMessage(BuildStateSummary(runState)),
|
||||
.. runState.BaseConversationMessages
|
||||
];
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(runState.CompactionSummary))
|
||||
{
|
||||
messages.Add(BuildSystemMessage($"""
|
||||
Older verified evidence summary
|
||||
{runState.CompactionSummary}
|
||||
"""));
|
||||
}
|
||||
|
||||
foreach (object message in runState.ToolConversationMessages.TakeLast(12))
|
||||
messages.Add(message);
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> BuildSystemMessage(string text)
|
||||
{
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["role"] = "system",
|
||||
["content"] = text
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildStateSummary(MccRunState runState)
|
||||
{
|
||||
string evidence = runState.Evidence.Count == 0
|
||||
? "- none yet"
|
||||
: string.Join('\n', runState.Evidence.TakeLast(6).Select(record =>
|
||||
$"- {record.Id} {record.ToolName}: {record.Summary}"));
|
||||
|
||||
string obligations = runState.OpenObligations.Count == 0
|
||||
? "- none"
|
||||
: string.Join('\n', runState.OpenObligations.Select(obligation =>
|
||||
$"- {obligation.Id} {obligation.ToolName}/{obligation.Kind}: {obligation.Description}"));
|
||||
|
||||
string bestPractices = runState.Guidance.BestPractices.Length == 0
|
||||
? "- use verified MCC state before claiming success"
|
||||
: string.Join('\n', runState.Guidance.BestPractices.Take(4).Select(item => $"- {item}"));
|
||||
|
||||
return $"""
|
||||
Current run state
|
||||
- turnCount: {runState.TurnCount}
|
||||
- toolCallCount: {runState.ToolCallCount}
|
||||
- directAnswerAttempts: {runState.DirectAnswerAttempts}
|
||||
- routedModel: {runState.RoutedModel ?? runState.ConfiguredModel}
|
||||
- routedProvider: {runState.RoutedProvider ?? "unknown"}
|
||||
|
||||
Outstanding verification
|
||||
{obligations}
|
||||
|
||||
Recent evidence
|
||||
{evidence}
|
||||
|
||||
Guidance highlights
|
||||
{bestPractices}
|
||||
""";
|
||||
}
|
||||
}
|
||||
103
DebugTools/MccMcpWebPlayground/Harness/MccRunState.cs
Normal file
103
DebugTools/MccMcpWebPlayground/Harness/MccRunState.cs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
using System.Text.Json;
|
||||
using DebugTools.MccMcpWebPlayground.Contracts;
|
||||
|
||||
namespace DebugTools.MccMcpWebPlayground.Harness;
|
||||
|
||||
public sealed class MccRunState
|
||||
{
|
||||
private int evidenceCounter;
|
||||
private int obligationCounter;
|
||||
|
||||
public required string RunId { get; init; }
|
||||
public required string UserRequest { get; init; }
|
||||
public required List<object> BaseConversationMessages { get; init; }
|
||||
public required string ConfiguredModel { get; init; }
|
||||
public required MccGuidanceBundle Guidance { get; init; }
|
||||
public DateTimeOffset StartedAtUtc { get; init; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public List<object> ToolConversationMessages { get; } = [];
|
||||
public List<MccEvidenceRecord> Evidence { get; } = [];
|
||||
public List<MccToolExecutionRecord> ToolExecutions { get; } = [];
|
||||
public List<MccVerificationObligation> VerificationObligations { get; } = [];
|
||||
public string? CompactionSummary { get; set; }
|
||||
public string? RoutedModel { get; set; }
|
||||
public string? RoutedProvider { get; set; }
|
||||
public int TurnCount { get; set; }
|
||||
public int ToolCallCount { get; set; }
|
||||
public int DirectAnswerAttempts { get; set; }
|
||||
|
||||
public string NextEvidenceId() => $"e{++evidenceCounter:0000}";
|
||||
|
||||
public string NextObligationId() => $"v{++obligationCounter:0000}";
|
||||
|
||||
public bool IsSoftFinish(MccWebHarnessOptions options, DateTimeOffset nowUtc)
|
||||
{
|
||||
TimeSpan elapsed = nowUtc - StartedAtUtc;
|
||||
return (options.MaxTurns - TurnCount) <= options.SoftFinishRemainingTurns
|
||||
|| (options.MaxToolCalls - ToolCallCount) <= options.SoftFinishRemainingToolCalls
|
||||
|| (options.MaxWallClockSeconds - (int)elapsed.TotalSeconds) <= options.SoftFinishRemainingSeconds;
|
||||
}
|
||||
|
||||
public bool IsHardStop(MccWebHarnessOptions options, DateTimeOffset nowUtc)
|
||||
{
|
||||
TimeSpan elapsed = nowUtc - StartedAtUtc;
|
||||
return TurnCount >= options.MaxTurns
|
||||
|| ToolCallCount >= options.MaxToolCalls
|
||||
|| elapsed.TotalSeconds >= options.MaxWallClockSeconds;
|
||||
}
|
||||
|
||||
public IReadOnlyList<MccVerificationObligation> OpenObligations =>
|
||||
VerificationObligations.Where(obligation => !obligation.Cleared).ToArray();
|
||||
}
|
||||
|
||||
public sealed record MccGuidanceBundle(
|
||||
string SourceToolName,
|
||||
string CanonicalPromptName,
|
||||
string SkillName,
|
||||
string GuidanceVersion,
|
||||
string SystemPrompt,
|
||||
string[] BestPractices,
|
||||
string[] ExampleScenarioTitles,
|
||||
MccCapabilityStatus CapabilityStatus);
|
||||
|
||||
public sealed class MccEvidenceRecord
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string ToolName { get; init; }
|
||||
public required string Summary { get; init; }
|
||||
public required string RawText { get; init; }
|
||||
public required bool IsError { get; init; }
|
||||
public required bool Success { get; init; }
|
||||
public string? ErrorCode { get; init; }
|
||||
public JsonElement? Root { get; init; }
|
||||
public JsonElement? Data { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccToolExecutionRecord
|
||||
{
|
||||
public required string CallId { get; init; }
|
||||
public required string ToolName { get; init; }
|
||||
public required string ArgumentsJson { get; init; }
|
||||
public required MccEvidenceRecord Evidence { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccVerificationObligation
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string ToolName { get; init; }
|
||||
public required string Kind { get; init; }
|
||||
public required string Description { get; init; }
|
||||
public required string SourceEvidenceId { get; init; }
|
||||
public JsonElement? Metadata { get; init; }
|
||||
public bool Cleared { get; set; }
|
||||
public string? ClearedByEvidenceId { get; set; }
|
||||
}
|
||||
|
||||
public sealed record MccNormalizedToolResult(
|
||||
string Text,
|
||||
bool IsError,
|
||||
bool Success,
|
||||
string? ErrorCode,
|
||||
string? Message,
|
||||
JsonElement? Root,
|
||||
JsonElement? Data);
|
||||
133
DebugTools/MccMcpWebPlayground/Harness/MccToolPolicy.cs
Normal file
133
DebugTools/MccMcpWebPlayground/Harness/MccToolPolicy.cs
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
using System.Collections.Frozen;
|
||||
using System.Text.Json.Nodes;
|
||||
using ModelContextProtocol.Client;
|
||||
|
||||
namespace DebugTools.MccMcpWebPlayground.Harness;
|
||||
|
||||
public enum MccToolRisk
|
||||
{
|
||||
ReadOnly,
|
||||
Stateful,
|
||||
Sensitive,
|
||||
EscapeHatch
|
||||
}
|
||||
|
||||
public sealed record MccToolProfile(
|
||||
string Name,
|
||||
MccToolRisk Risk,
|
||||
bool VisibleByDefault,
|
||||
bool RequiresExplicitUserIntent);
|
||||
|
||||
public sealed record MccToolCatalogEntry(McpClientTool Tool, MccToolProfile Profile);
|
||||
|
||||
public sealed class MccToolCatalog
|
||||
{
|
||||
public required Dictionary<string, MccToolCatalogEntry> ToolsByName { get; init; }
|
||||
public required IReadOnlyList<object> ModelVisibleTools { get; init; }
|
||||
}
|
||||
|
||||
public static class MccToolPolicy
|
||||
{
|
||||
private static readonly FrozenDictionary<string, MccToolProfile> Profiles =
|
||||
new Dictionary<string, MccToolProfile>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["mcc_agent_guidance"] = new("mcc_agent_guidance", MccToolRisk.ReadOnly, false, false),
|
||||
["mcc_inventory_window_action"] = new("mcc_inventory_window_action", MccToolRisk.EscapeHatch, false, false),
|
||||
["mcc_run_internal_command"] = new("mcc_run_internal_command", MccToolRisk.EscapeHatch, false, false),
|
||||
["mcc_quit_client"] = new("mcc_quit_client", MccToolRisk.Sensitive, true, true)
|
||||
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static MccToolProfile GetProfile(string toolName)
|
||||
{
|
||||
return Profiles.TryGetValue(toolName, out MccToolProfile? profile)
|
||||
? profile
|
||||
: new MccToolProfile(toolName, MccToolRisk.Stateful, true, false);
|
||||
}
|
||||
|
||||
public static MccToolCatalog BuildCatalog(IList<McpClientTool> tools, MccWebHarnessOptions options, object submitFinalTool)
|
||||
{
|
||||
Dictionary<string, MccToolCatalogEntry> toolsByName = tools.ToDictionary(
|
||||
tool => tool.Name,
|
||||
tool => new MccToolCatalogEntry(tool, GetProfile(tool.Name)),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
List<object> visibleTools = [];
|
||||
foreach (MccToolCatalogEntry entry in toolsByName.Values.OrderBy(entry => entry.Tool.Name, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!IsVisible(entry.Profile, options))
|
||||
continue;
|
||||
|
||||
visibleTools.Add(ToOpenRouterTool(entry.Tool, entry.Profile));
|
||||
}
|
||||
|
||||
visibleTools.Add(submitFinalTool);
|
||||
|
||||
return new MccToolCatalog
|
||||
{
|
||||
ToolsByName = toolsByName,
|
||||
ModelVisibleTools = visibleTools
|
||||
};
|
||||
}
|
||||
|
||||
public static bool RequiresExplicitUserIntent(string toolName)
|
||||
{
|
||||
return GetProfile(toolName).RequiresExplicitUserIntent;
|
||||
}
|
||||
|
||||
public static bool HasExplicitUserIntent(string userRequest, string toolName)
|
||||
{
|
||||
if (!RequiresExplicitUserIntent(toolName))
|
||||
return true;
|
||||
|
||||
string request = userRequest.Trim().ToLowerInvariant();
|
||||
return toolName.Equals("mcc_quit_client", StringComparison.OrdinalIgnoreCase)
|
||||
&& (request.Contains("quit mcc", StringComparison.Ordinal)
|
||||
|| request.Contains("close mcc", StringComparison.Ordinal)
|
||||
|| request.Contains("stop mcc", StringComparison.Ordinal)
|
||||
|| request.Contains("exit mcc", StringComparison.Ordinal)
|
||||
|| request.Contains("quit the client", StringComparison.Ordinal)
|
||||
|| request.Contains("stop the client", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private static bool IsVisible(MccToolProfile profile, MccWebHarnessOptions options)
|
||||
{
|
||||
if (!profile.VisibleByDefault)
|
||||
{
|
||||
if (profile.Name.Equals("mcc_inventory_window_action", StringComparison.OrdinalIgnoreCase))
|
||||
return options.ExposeInventoryWindowAction;
|
||||
|
||||
if (profile.Name.Equals("mcc_run_internal_command", StringComparison.OrdinalIgnoreCase))
|
||||
return options.ExposeInternalCommandTool;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static object ToOpenRouterTool(McpClientTool tool, MccToolProfile profile)
|
||||
{
|
||||
JsonNode parameters = JsonNode.Parse(tool.JsonSchema.GetRawText()) ?? new JsonObject
|
||||
{
|
||||
["type"] = "object",
|
||||
["properties"] = new JsonObject()
|
||||
};
|
||||
|
||||
string description = tool.Description ?? string.Empty;
|
||||
if (profile.Risk == MccToolRisk.Sensitive)
|
||||
description = $"{description} Requires explicit user intent.";
|
||||
else if (profile.Risk == MccToolRisk.EscapeHatch)
|
||||
description = $"{description} Advanced escape hatch; prefer higher-level tools first.";
|
||||
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["type"] = "function",
|
||||
["function"] = new Dictionary<string, object?>
|
||||
{
|
||||
["name"] = tool.Name,
|
||||
["description"] = description,
|
||||
["parameters"] = parameters
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
namespace DebugTools.MccMcpWebPlayground.Harness;
|
||||
|
||||
public sealed class MccWebHarnessOptions
|
||||
{
|
||||
public const string SectionName = "MccWebHarness";
|
||||
|
||||
public string? Model { get; set; }
|
||||
public string OpenRouterBaseUrl { get; set; } = "https://openrouter.ai/api/v1";
|
||||
public string McpEndpoint { get; set; } = "http://127.0.0.1:33333/mcp";
|
||||
public int MaxTurns { get; set; } = 48;
|
||||
public int MaxToolCalls { get; set; } = 120;
|
||||
public int MaxWallClockSeconds { get; set; } = 240;
|
||||
public int SoftFinishRemainingTurns { get; set; } = 3;
|
||||
public int SoftFinishRemainingToolCalls { get; set; } = 8;
|
||||
public int SoftFinishRemainingSeconds { get; set; } = 30;
|
||||
public bool RequireProviderParameters { get; set; } = true;
|
||||
public bool AllowFallbacks { get; set; }
|
||||
public bool DisableParallelToolCalls { get; set; } = true;
|
||||
public bool ExposeInventoryWindowAction { get; set; }
|
||||
public bool ExposeInternalCommandTool { get; set; }
|
||||
|
||||
public string? ResolveModel()
|
||||
{
|
||||
return FirstNonEmpty(Environment.GetEnvironmentVariable("OPENROUTER_MODEL"), Model);
|
||||
}
|
||||
|
||||
public string ResolveOpenRouterBaseUrl()
|
||||
{
|
||||
return FirstNonEmpty(Environment.GetEnvironmentVariable("OPENROUTER_BASE_URL"), OpenRouterBaseUrl)
|
||||
?? "https://openrouter.ai/api/v1";
|
||||
}
|
||||
|
||||
public string ResolveMcpEndpoint()
|
||||
{
|
||||
return FirstNonEmpty(Environment.GetEnvironmentVariable("MCC_MCP_ENDPOINT"), McpEndpoint)
|
||||
?? "http://127.0.0.1:33333/mcp";
|
||||
}
|
||||
|
||||
public string? ResolveMcpAuthToken()
|
||||
{
|
||||
return Environment.GetEnvironmentVariable("MCC_MCP_AUTH_TOKEN");
|
||||
}
|
||||
|
||||
public string? ResolveApiKey()
|
||||
{
|
||||
return Environment.GetEnvironmentVariable("OPENROUTER_API_KEY");
|
||||
}
|
||||
|
||||
public bool HasApiKeyConfigured()
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(ResolveApiKey());
|
||||
}
|
||||
|
||||
private static string? FirstNonEmpty(params string?[] candidates)
|
||||
{
|
||||
return candidates.FirstOrDefault(candidate => !string.IsNullOrWhiteSpace(candidate))?.Trim();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Reflection;
|
||||
using DebugTools.MccMcpWebPlayground.Harness;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace DebugTools.MccMcpWebPlayground.Infrastructure.Mcp;
|
||||
|
||||
public sealed class MccMcpSessionFactory
|
||||
{
|
||||
private readonly MccWebHarnessOptions options;
|
||||
|
||||
public MccMcpSessionFactory(IOptions<MccWebHarnessOptions> options)
|
||||
{
|
||||
this.options = options.Value;
|
||||
}
|
||||
|
||||
public async Task<McpClient> CreateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
string endpoint = options.ResolveMcpEndpoint();
|
||||
string? token = options.ResolveMcpAuthToken();
|
||||
|
||||
return await McpClient.CreateAsync(new HttpClientTransport(new HttpClientTransportOptions
|
||||
{
|
||||
Endpoint = new Uri(endpoint),
|
||||
TransportMode = HttpTransportMode.AutoDetect,
|
||||
AdditionalHeaders = string.IsNullOrWhiteSpace(token)
|
||||
? null
|
||||
: new Dictionary<string, string>
|
||||
{
|
||||
["Authorization"] = $"Bearer {token}"
|
||||
}
|
||||
}), cancellationToken: cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public static class MccMcpJson
|
||||
{
|
||||
public static MccNormalizedToolResult Normalize(CallToolResult result)
|
||||
{
|
||||
JsonElement? structuredRoot = TryReadStructuredContent(result);
|
||||
string text = ReadToolResultText(result, structuredRoot);
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(text);
|
||||
JsonElement parsedRoot = document.RootElement.Clone();
|
||||
JsonElement root = ShouldPreferStructuredRoot(parsedRoot, structuredRoot)
|
||||
? structuredRoot!.Value
|
||||
: parsedRoot;
|
||||
JsonElement? data = root.TryGetProperty("data", out JsonElement dataElement)
|
||||
? dataElement.Clone()
|
||||
: ShouldTreatRootAsData(root) ? root.Clone() : structuredRoot;
|
||||
bool success = root.TryGetProperty("success", out JsonElement successElement)
|
||||
? successElement.ValueKind != JsonValueKind.False
|
||||
: result.IsError != true;
|
||||
string? errorCode = root.TryGetProperty("errorCode", out JsonElement errorCodeElement) && errorCodeElement.ValueKind == JsonValueKind.String
|
||||
? errorCodeElement.GetString()
|
||||
: null;
|
||||
string? message = root.TryGetProperty("message", out JsonElement messageElement) && messageElement.ValueKind == JsonValueKind.String
|
||||
? messageElement.GetString()
|
||||
: null;
|
||||
bool isError = result.IsError == true || !success || !string.IsNullOrWhiteSpace(errorCode);
|
||||
|
||||
return new MccNormalizedToolResult(text, isError, success, errorCode, message, root, data);
|
||||
}
|
||||
catch
|
||||
{
|
||||
bool isError = result.IsError == true;
|
||||
return new MccNormalizedToolResult(text, isError, !isError, null, null, structuredRoot, structuredRoot);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadToolResultText(CallToolResult result, JsonElement? structuredRoot)
|
||||
{
|
||||
if (result.Content is null)
|
||||
return structuredRoot?.GetRawText() ?? (result.IsError == true ? "{\"success\":false}" : "{\"success\":true}");
|
||||
|
||||
StringBuilder builder = new();
|
||||
foreach (ContentBlock block in result.Content)
|
||||
{
|
||||
if (block is TextContentBlock text && !string.IsNullOrWhiteSpace(text.Text))
|
||||
{
|
||||
if (builder.Length > 0)
|
||||
builder.Append('\n');
|
||||
builder.Append(text.Text);
|
||||
}
|
||||
}
|
||||
|
||||
return builder.Length > 0
|
||||
? builder.ToString()
|
||||
: structuredRoot?.GetRawText()
|
||||
?? JsonSerializer.Serialize(new { success = result.IsError != true, isError = result.IsError });
|
||||
}
|
||||
|
||||
private static JsonElement? TryReadStructuredContent(CallToolResult result)
|
||||
{
|
||||
PropertyInfo? property = typeof(CallToolResult).GetProperty("StructuredContent", BindingFlags.Instance | BindingFlags.Public);
|
||||
if (property?.GetValue(result) is not { } value)
|
||||
return null;
|
||||
|
||||
return value switch
|
||||
{
|
||||
JsonElement json when json.ValueKind != JsonValueKind.Undefined && json.ValueKind != JsonValueKind.Null => json.Clone(),
|
||||
JsonDocument document => document.RootElement.Clone(),
|
||||
string text when !string.IsNullOrWhiteSpace(text) => TryParseJson(text),
|
||||
_ => TrySerializeToJson(value)
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonElement? TrySerializeToJson(object value)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.SerializeToElement(value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonElement? TryParseJson(string text)
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(text);
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldPreferStructuredRoot(JsonElement parsedRoot, JsonElement? structuredRoot)
|
||||
{
|
||||
if (structuredRoot is null)
|
||||
return false;
|
||||
|
||||
if (parsedRoot.ValueKind != JsonValueKind.Object)
|
||||
return true;
|
||||
|
||||
return !parsedRoot.EnumerateObject().Any(property =>
|
||||
!property.NameEquals("success") &&
|
||||
!property.NameEquals("isError"));
|
||||
}
|
||||
|
||||
private static bool ShouldTreatRootAsData(JsonElement root)
|
||||
{
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
return false;
|
||||
|
||||
return root.EnumerateObject().Any(property =>
|
||||
!property.NameEquals("success") &&
|
||||
!property.NameEquals("isError") &&
|
||||
!property.NameEquals("errorCode") &&
|
||||
!property.NameEquals("message"));
|
||||
}
|
||||
}
|
||||
|
||||
public static class MccJsonArguments
|
||||
{
|
||||
public static Dictionary<string, object?> Parse(string rawJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(string.IsNullOrWhiteSpace(rawJson) ? "{}" : rawJson);
|
||||
if (document.RootElement.ValueKind != JsonValueKind.Object)
|
||||
return new Dictionary<string, object?>();
|
||||
|
||||
Dictionary<string, object?> values = new(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (JsonProperty property in document.RootElement.EnumerateObject())
|
||||
values[property.Name] = Convert(property.Value);
|
||||
return values;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new Dictionary<string, object?>();
|
||||
}
|
||||
}
|
||||
|
||||
private static object? Convert(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Null => null,
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Number => element.TryGetInt64(out long i64)
|
||||
? i64
|
||||
: element.TryGetDouble(out double d) ? d : element.GetRawText(),
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.Array => element.EnumerateArray().Select(Convert).ToArray(),
|
||||
JsonValueKind.Object => element.EnumerateObject().ToDictionary(property => property.Name, property => Convert(property.Value)),
|
||||
_ => element.GetRawText()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DebugTools.MccMcpWebPlayground.Harness;
|
||||
|
||||
namespace DebugTools.MccMcpWebPlayground.Infrastructure.OpenRouter;
|
||||
|
||||
public sealed class OpenRouterChatClient
|
||||
{
|
||||
private readonly IHttpClientFactory httpClientFactory;
|
||||
|
||||
public OpenRouterChatClient(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
this.httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
public async Task<MccModelTurn> CreateTurnAsync(
|
||||
List<object> messages,
|
||||
IReadOnlyList<object> tools,
|
||||
MccWebHarnessOptions options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string apiKey = options.ResolveApiKey() ?? throw new InvalidOperationException("OPENROUTER_API_KEY is not configured.");
|
||||
string model = options.ResolveModel() ?? throw new InvalidOperationException("Model is not configured.");
|
||||
|
||||
using HttpClient client = httpClientFactory.CreateClient("openrouter");
|
||||
client.BaseAddress = new Uri(options.ResolveOpenRouterBaseUrl().TrimEnd('/') + "/");
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("HTTP-Referer", "https://localhost/mcc-mcp-web-playground");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("X-Title", "MCC MCP Web Playground");
|
||||
|
||||
Dictionary<string, object?> payload = new()
|
||||
{
|
||||
["model"] = model,
|
||||
["messages"] = messages,
|
||||
["tools"] = tools,
|
||||
["tool_choice"] = "auto",
|
||||
["provider"] = new Dictionary<string, object?>
|
||||
{
|
||||
["allow_fallbacks"] = options.AllowFallbacks,
|
||||
["require_parameters"] = options.RequireProviderParameters
|
||||
}
|
||||
};
|
||||
|
||||
if (ShouldSendParallelToolCallsParameter(model))
|
||||
payload["parallel_tool_calls"] = !options.DisableParallelToolCalls;
|
||||
|
||||
using HttpResponseMessage response = await client.PostAsync(
|
||||
"chat/completions",
|
||||
new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"),
|
||||
cancellationToken);
|
||||
|
||||
string body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new InvalidOperationException($"OpenRouter returned HTTP {(int)response.StatusCode}: {body}");
|
||||
|
||||
using JsonDocument document = JsonDocument.Parse(body);
|
||||
if (!document.RootElement.TryGetProperty("choices", out JsonElement choices)
|
||||
|| choices.ValueKind != JsonValueKind.Array
|
||||
|| choices.GetArrayLength() == 0)
|
||||
{
|
||||
throw new InvalidOperationException("OpenRouter did not return any choices.");
|
||||
}
|
||||
|
||||
JsonElement message = choices[0].GetProperty("message");
|
||||
string assistantContent = message.TryGetProperty("content", out JsonElement contentElement)
|
||||
? contentElement.GetString() ?? string.Empty
|
||||
: string.Empty;
|
||||
|
||||
List<MccModelToolCall> toolCalls = [];
|
||||
if (message.TryGetProperty("tool_calls", out JsonElement toolCallsElement) && toolCallsElement.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (JsonElement toolCall in toolCallsElement.EnumerateArray())
|
||||
{
|
||||
if (!toolCall.TryGetProperty("id", out JsonElement idElement)
|
||||
|| !toolCall.TryGetProperty("function", out JsonElement functionElement)
|
||||
|| !functionElement.TryGetProperty("name", out JsonElement nameElement))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
toolCalls.Add(new MccModelToolCall(
|
||||
CallId: idElement.GetString() ?? Guid.NewGuid().ToString("n"),
|
||||
Name: nameElement.GetString() ?? string.Empty,
|
||||
ArgumentsJson: functionElement.TryGetProperty("arguments", out JsonElement argumentsElement)
|
||||
? argumentsElement.GetString() ?? "{}"
|
||||
: "{}"));
|
||||
}
|
||||
}
|
||||
|
||||
string modelId = document.RootElement.TryGetProperty("model", out JsonElement modelElement)
|
||||
? modelElement.GetString() ?? model
|
||||
: model;
|
||||
|
||||
string? routedProvider = response.Headers.TryGetValues("x-openrouter-provider", out IEnumerable<string>? providerValues)
|
||||
? providerValues.FirstOrDefault()
|
||||
: null;
|
||||
|
||||
return new MccModelTurn(modelId, routedProvider, assistantContent, toolCalls);
|
||||
}
|
||||
|
||||
private static bool ShouldSendParallelToolCallsParameter(string model)
|
||||
{
|
||||
// Some OpenRouter model families reject tool-enabled requests when the parallel_tool_calls
|
||||
// parameter is present at all, even if it is explicitly set to false. The harness still
|
||||
// executes all returned tool calls sequentially, so omitting the transport hint for those
|
||||
// families preserves the intended runtime behavior while keeping the stricter flag for
|
||||
// compatible models.
|
||||
return !model.StartsWith("minimax/", StringComparison.OrdinalIgnoreCase)
|
||||
&& !model.StartsWith("google/gemini-", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record MccModelTurn(
|
||||
string ModelId,
|
||||
string? RoutedProvider,
|
||||
string AssistantContent,
|
||||
IReadOnlyList<MccModelToolCall> ToolCalls);
|
||||
|
||||
public sealed record MccModelToolCall(string CallId, string Name, string ArgumentsJson);
|
||||
14
DebugTools/MccMcpWebPlayground/MccMcpWebPlayground.csproj
Normal file
14
DebugTools/MccMcpWebPlayground/MccMcpWebPlayground.csproj
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AllowMissingPrunePackageData>true</AllowMissingPrunePackageData>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ModelContextProtocol" Version="1.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
40
DebugTools/MccMcpWebPlayground/Program.cs
Normal file
40
DebugTools/MccMcpWebPlayground/Program.cs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
using DebugTools.MccMcpWebPlayground.Api;
|
||||
using DebugTools.MccMcpWebPlayground.Harness;
|
||||
using DebugTools.MccMcpWebPlayground.Infrastructure.Mcp;
|
||||
using DebugTools.MccMcpWebPlayground.Infrastructure.OpenRouter;
|
||||
using Microsoft.AspNetCore.Http.Timeouts;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddOptions<MccWebHarnessOptions>()
|
||||
.Bind(builder.Configuration.GetSection(MccWebHarnessOptions.SectionName));
|
||||
|
||||
builder.Services.AddRequestTimeouts(options =>
|
||||
{
|
||||
options.AddPolicy("mcc-stream", new RequestTimeoutPolicy
|
||||
{
|
||||
Timeout = TimeSpan.FromMinutes(10)
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient("openrouter", client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromMinutes(15);
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<MccMcpSessionFactory>();
|
||||
builder.Services.AddSingleton<MccGuidanceSource>();
|
||||
builder.Services.AddSingleton<MccContextCompressor>();
|
||||
builder.Services.AddSingleton<MccFinalizer>();
|
||||
builder.Services.AddSingleton<MccPromptComposer>();
|
||||
builder.Services.AddSingleton<OpenRouterChatClient>();
|
||||
builder.Services.AddScoped<IMccAgentRunService, MccAgentRunService>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseRequestTimeouts();
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
app.MapMccPlaygroundEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5295",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7104;http://localhost:5295",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
14
DebugTools/MccMcpWebPlayground/appsettings.Development.json
Normal file
14
DebugTools/MccMcpWebPlayground/appsettings.Development.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"MccWebHarness": {
|
||||
"AllowFallbacks": false,
|
||||
"DisableParallelToolCalls": true,
|
||||
"ExposeInventoryWindowAction": false,
|
||||
"ExposeInternalCommandTool": false
|
||||
}
|
||||
}
|
||||
24
DebugTools/MccMcpWebPlayground/appsettings.json
Normal file
24
DebugTools/MccMcpWebPlayground/appsettings.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"MccWebHarness": {
|
||||
"OpenRouterBaseUrl": "https://openrouter.ai/api/v1",
|
||||
"McpEndpoint": "http://127.0.0.1:33333/mcp",
|
||||
"MaxTurns": 48,
|
||||
"MaxToolCalls": 120,
|
||||
"MaxWallClockSeconds": 240,
|
||||
"SoftFinishRemainingTurns": 3,
|
||||
"SoftFinishRemainingToolCalls": 8,
|
||||
"SoftFinishRemainingSeconds": 30,
|
||||
"RequireProviderParameters": true,
|
||||
"AllowFallbacks": false,
|
||||
"DisableParallelToolCalls": true,
|
||||
"ExposeInventoryWindowAction": false,
|
||||
"ExposeInternalCommandTool": false
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
310
DebugTools/MccMcpWebPlayground/wwwroot/app.js
Normal file
310
DebugTools/MccMcpWebPlayground/wwwroot/app.js
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
const html = document.documentElement;
|
||||
const statusEl = document.getElementById("status");
|
||||
const sendBtn = document.getElementById("send");
|
||||
const stopBtn = document.getElementById("stop");
|
||||
const clearBtn = document.getElementById("clear");
|
||||
const clearChatBtn = document.getElementById("clear-chat-btn");
|
||||
const clearToolsBtn = document.getElementById("clear-tools-btn");
|
||||
const promptEl = document.getElementById("prompt");
|
||||
const chatEl = document.getElementById("chat");
|
||||
const toolsEl = document.getElementById("tools");
|
||||
const emptyStateEl = document.getElementById("empty-state");
|
||||
const toolsEmptyStateEl = document.getElementById("tools-empty-state");
|
||||
const typingIndicatorEl = document.getElementById("typing-indicator");
|
||||
const themeToggleBtn = document.getElementById("theme-toggle");
|
||||
const themeToggleIconEl = document.getElementById("theme-toggle-icon");
|
||||
|
||||
let history = [];
|
||||
let activeAssistantBody = null;
|
||||
let abortController = null;
|
||||
|
||||
stopBtn.disabled = true;
|
||||
|
||||
loadTheme();
|
||||
loadConfig();
|
||||
|
||||
themeToggleBtn.addEventListener("click", () => {
|
||||
const next = html.getAttribute("data-theme") === "dark" ? "light" : "dark";
|
||||
setTheme(next);
|
||||
});
|
||||
|
||||
sendBtn.addEventListener("click", sendPrompt);
|
||||
stopBtn.addEventListener("click", () => abortController?.abort());
|
||||
|
||||
clearBtn.addEventListener("click", () => {
|
||||
history = [];
|
||||
removeAllMessages();
|
||||
removeAllTimelineEvents();
|
||||
promptEl.value = "";
|
||||
activeAssistantBody = null;
|
||||
updateEmptyStates();
|
||||
});
|
||||
|
||||
clearChatBtn.addEventListener("click", () => {
|
||||
history = [];
|
||||
removeAllMessages();
|
||||
promptEl.value = "";
|
||||
activeAssistantBody = null;
|
||||
updateEmptyStates();
|
||||
});
|
||||
|
||||
clearToolsBtn.addEventListener("click", () => {
|
||||
removeAllTimelineEvents();
|
||||
updateEmptyStates();
|
||||
});
|
||||
|
||||
promptEl.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
sendPrompt();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const response = await fetch("/api/config");
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const config = await response.json();
|
||||
const modelLabel = config.model ? config.model : "Model not configured";
|
||||
statusEl.textContent = config.hasApiKey ? modelLabel : `${modelLabel} / missing OPENROUTER_API_KEY`;
|
||||
} catch (error) {
|
||||
statusEl.textContent = `Config error: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPrompt() {
|
||||
const prompt = promptEl.value.trim();
|
||||
if (!prompt || abortController) {
|
||||
return;
|
||||
}
|
||||
|
||||
history.push({ role: "user", content: prompt });
|
||||
addMessage("user", prompt);
|
||||
promptEl.value = "";
|
||||
activeAssistantBody = addMessage("assistant", "");
|
||||
setBusy(true);
|
||||
|
||||
abortController = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/chat/stream", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ messages: history }),
|
||||
signal: abortController.signal
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let finalAssistantText = "";
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
buffer = parseSseChunk(buffer, (eventName, envelope) => {
|
||||
addTimelineEvent(eventName, envelope);
|
||||
|
||||
if (eventName === "error") {
|
||||
const errorMessage = envelope.data?.message ?? "Unknown error";
|
||||
addMessage("error", errorMessage);
|
||||
}
|
||||
|
||||
if (eventName === "final") {
|
||||
finalAssistantText = formatFinalText(envelope.data);
|
||||
activeAssistantBody.textContent = finalAssistantText;
|
||||
}
|
||||
|
||||
if (eventName === "state_summary") {
|
||||
const turnCount = envelope.data?.turnCount ?? "?";
|
||||
const toolCallCount = envelope.data?.toolCallCount ?? "?";
|
||||
statusEl.textContent = `Running turn ${turnCount}, tools ${toolCallCount}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (finalAssistantText.trim().length > 0) {
|
||||
history.push({ role: "assistant", content: finalAssistantText });
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.name !== "AbortError") {
|
||||
addMessage("error", `Request failed: ${error.message}`);
|
||||
addTimelineEvent("error", {
|
||||
kind: "error",
|
||||
data: {
|
||||
code: "request_failed",
|
||||
message: error.message
|
||||
}
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
abortController = null;
|
||||
activeAssistantBody = null;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSseChunk(buffer, onEvent) {
|
||||
let blockIndex;
|
||||
while ((blockIndex = buffer.indexOf("\n\n")) >= 0) {
|
||||
const rawBlock = buffer.slice(0, blockIndex);
|
||||
buffer = buffer.slice(blockIndex + 2);
|
||||
|
||||
let eventName = "message";
|
||||
let dataText = "";
|
||||
for (const line of rawBlock.split("\n")) {
|
||||
if (line.startsWith("event:")) {
|
||||
eventName = line.slice(6).trim();
|
||||
} else if (line.startsWith("data:")) {
|
||||
dataText += line.slice(5).trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (!dataText) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
onEvent(eventName, JSON.parse(dataText));
|
||||
} catch (error) {
|
||||
onEvent("error", {
|
||||
kind: "error",
|
||||
data: {
|
||||
code: "invalid_sse_payload",
|
||||
message: "Failed to parse SSE payload.",
|
||||
detail: dataText
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function addMessage(role, content) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = `message ${role}`;
|
||||
|
||||
const label = document.createElement("div");
|
||||
label.className = "message-label";
|
||||
label.textContent = role;
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "message-body";
|
||||
body.textContent = content;
|
||||
|
||||
wrapper.append(label, body);
|
||||
chatEl.insertBefore(wrapper, typingIndicatorEl);
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
updateEmptyStates();
|
||||
return body;
|
||||
}
|
||||
|
||||
function addTimelineEvent(kind, envelope) {
|
||||
const event = document.createElement("div");
|
||||
event.className = `timeline-event kind-${kind}`;
|
||||
|
||||
const label = document.createElement("div");
|
||||
label.className = "timeline-label";
|
||||
label.textContent = kind.replaceAll("_", " ");
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "timeline-body-text";
|
||||
body.textContent = JSON.stringify(envelope.data ?? envelope, null, 2);
|
||||
|
||||
event.append(label, body);
|
||||
toolsEl.appendChild(event);
|
||||
toolsEl.scrollTop = toolsEl.scrollHeight;
|
||||
updateEmptyStates();
|
||||
}
|
||||
|
||||
function formatFinalText(data) {
|
||||
if (!data) {
|
||||
return "The run completed without a final payload.";
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
if (data.headline) {
|
||||
lines.push(data.headline);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
if (data.answerMarkdown) {
|
||||
lines.push(data.answerMarkdown);
|
||||
}
|
||||
|
||||
if (Array.isArray(data.verifiedFacts) && data.verifiedFacts.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("Verified facts:");
|
||||
for (const fact of data.verifiedFacts) {
|
||||
lines.push(`- ${fact}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(data.openIssues) && data.openIssues.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("Open issues:");
|
||||
for (const issue of data.openIssues) {
|
||||
lines.push(`- ${issue}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.nextAction) {
|
||||
lines.push("");
|
||||
lines.push(`Next action: ${data.nextAction}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function setBusy(busy) {
|
||||
sendBtn.disabled = busy;
|
||||
stopBtn.disabled = !busy;
|
||||
promptEl.disabled = busy;
|
||||
typingIndicatorEl.classList.toggle("visible", busy);
|
||||
statusEl.classList.toggle("busy", busy);
|
||||
if (!busy) {
|
||||
loadConfig();
|
||||
} else {
|
||||
statusEl.textContent = "Streaming run...";
|
||||
}
|
||||
}
|
||||
|
||||
function removeAllMessages() {
|
||||
for (const message of chatEl.querySelectorAll(".message")) {
|
||||
message.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function removeAllTimelineEvents() {
|
||||
for (const event of toolsEl.querySelectorAll(".timeline-event")) {
|
||||
event.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function updateEmptyStates() {
|
||||
emptyStateEl.style.display = chatEl.querySelectorAll(".message").length === 0 ? "" : "none";
|
||||
toolsEmptyStateEl.style.display = toolsEl.querySelectorAll(".timeline-event").length === 0 ? "" : "none";
|
||||
}
|
||||
|
||||
function loadTheme() {
|
||||
const theme = localStorage.getItem("mcc-playground-theme") || "dark";
|
||||
setTheme(theme);
|
||||
}
|
||||
|
||||
function setTheme(theme) {
|
||||
html.setAttribute("data-theme", theme);
|
||||
themeToggleIconEl.textContent = theme === "dark" ? "◎" : "◐";
|
||||
localStorage.setItem("mcc-playground-theme", theme);
|
||||
}
|
||||
79
DebugTools/MccMcpWebPlayground/wwwroot/index.html
Normal file
79
DebugTools/MccMcpWebPlayground/wwwroot/index.html
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MCC MCP Playground</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Syne:wght@500;700;800&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<div class="brand-dot"></div>
|
||||
<div>
|
||||
<div class="brand-title"><span>MCC</span> MCP Playground</div>
|
||||
<div class="brand-subtitle">Canonical guidance bootstrap, typed run state, verified completion</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<div id="status" class="status-pill">Booting...</div>
|
||||
<button id="theme-toggle" class="icon-button" title="Toggle theme" aria-label="Toggle theme">
|
||||
<span id="theme-toggle-icon">◎</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<section class="panel chat-panel">
|
||||
<div class="panel-header">
|
||||
<h1>Conversation</h1>
|
||||
<div class="panel-actions">
|
||||
<button id="clear-chat-btn" class="icon-button" title="Clear chat">Clear Chat</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="chat" class="panel-body chat-body">
|
||||
<div id="empty-state" class="empty-state">
|
||||
<p>No messages yet.</p>
|
||||
<p>Ask the harness to inspect or act through MCC's MCP server.</p>
|
||||
</div>
|
||||
<div id="typing-indicator" class="typing-indicator">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel timeline-panel">
|
||||
<div class="panel-header">
|
||||
<h1>Run Timeline</h1>
|
||||
<div class="panel-actions">
|
||||
<button id="clear-tools-btn" class="icon-button" title="Clear timeline">Clear Events</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tools" class="panel-body timeline-body">
|
||||
<div id="tools-empty-state" class="empty-state">
|
||||
<p>No run events yet.</p>
|
||||
<p>Typed SSE events will appear here as the harness runs.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="composer">
|
||||
<div class="composer-row">
|
||||
<textarea id="prompt" placeholder="Ask MCC to inspect, move, or interact through the web harness..."></textarea>
|
||||
</div>
|
||||
<div class="composer-footer">
|
||||
<div class="composer-hint">Press <kbd>Enter</kbd> to send, <kbd>Shift</kbd> + <kbd>Enter</kbd> for a new line.</div>
|
||||
<div class="composer-actions">
|
||||
<button id="stop" class="ghost-button">Stop</button>
|
||||
<button id="clear" class="ghost-button">Clear All</button>
|
||||
<button id="send" class="primary-button">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
383
DebugTools/MccMcpWebPlayground/wwwroot/site.css
Normal file
383
DebugTools/MccMcpWebPlayground/wwwroot/site.css
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
:root {
|
||||
--bg: #07111e;
|
||||
--bg-alt: #0b1828;
|
||||
--panel: rgba(10, 21, 36, 0.88);
|
||||
--panel-strong: rgba(8, 18, 30, 0.96);
|
||||
--border: rgba(111, 179, 255, 0.18);
|
||||
--text: #dce9ff;
|
||||
--text-dim: #8ca4c8;
|
||||
--text-soft: #607695;
|
||||
--accent: #75e7c7;
|
||||
--accent-strong: #4ad3ff;
|
||||
--warning: #ffcc66;
|
||||
--danger: #ff7b8b;
|
||||
--shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg: #edf4ff;
|
||||
--bg-alt: #dfeaff;
|
||||
--panel: rgba(255, 255, 255, 0.88);
|
||||
--panel-strong: rgba(255, 255, 255, 0.96);
|
||||
--border: rgba(28, 89, 164, 0.14);
|
||||
--text: #172843;
|
||||
--text-dim: #4d6383;
|
||||
--text-soft: #7d90ad;
|
||||
--accent: #0f936d;
|
||||
--accent-strong: #006cbb;
|
||||
--warning: #a56700;
|
||||
--danger: #ba2741;
|
||||
--shadow: 0 20px 60px rgba(61, 89, 138, 0.12);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
gap: 16px;
|
||||
padding: 18px;
|
||||
color: var(--text);
|
||||
font-family: "Space Mono", monospace;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(74, 211, 255, 0.12), transparent 35%),
|
||||
radial-gradient(circle at right center, rgba(117, 231, 199, 0.08), transparent 40%),
|
||||
linear-gradient(160deg, var(--bg), var(--bg-alt));
|
||||
}
|
||||
|
||||
button,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.panel,
|
||||
.composer {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 18px;
|
||||
background: var(--panel);
|
||||
backdrop-filter: blur(18px);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 18px 20px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.brand-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||
box-shadow: 0 0 18px rgba(117, 231, 199, 0.55);
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-family: "Syne", sans-serif;
|
||||
font-size: 1rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.brand-title span {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
margin-top: 4px;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-dim);
|
||||
font-size: 0.72rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-pill.busy {
|
||||
color: var(--accent-strong);
|
||||
border-color: rgba(74, 211, 255, 0.4);
|
||||
}
|
||||
|
||||
.icon-button,
|
||||
.ghost-button,
|
||||
.primary-button {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
color: var(--text);
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s ease, border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.icon-button:hover,
|
||||
.ghost-button:hover,
|
||||
.primary-button:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(117, 231, 199, 0.4);
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-strong));
|
||||
color: #06101a;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.layout {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 1.2fr 0.9fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.panel-header h1 {
|
||||
margin: 0;
|
||||
font-family: "Syne", sans-serif;
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.chat-body,
|
||||
.timeline-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message,
|
||||
.timeline-event {
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 14px 16px;
|
||||
background: var(--panel-strong);
|
||||
}
|
||||
|
||||
.message.user {
|
||||
background: rgba(74, 211, 255, 0.09);
|
||||
}
|
||||
|
||||
.message.assistant {
|
||||
background: rgba(117, 231, 199, 0.06);
|
||||
}
|
||||
|
||||
.message.error {
|
||||
background: rgba(255, 123, 139, 0.08);
|
||||
border-color: rgba(255, 123, 139, 0.2);
|
||||
}
|
||||
|
||||
.message-label,
|
||||
.timeline-label {
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.message-body,
|
||||
.timeline-body-text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.6;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.timeline-event.kind-tool_called {
|
||||
border-left: 4px solid var(--accent-strong);
|
||||
}
|
||||
|
||||
.timeline-event.kind-tool_result {
|
||||
border-left: 4px solid var(--accent);
|
||||
}
|
||||
|
||||
.timeline-event.kind-error {
|
||||
border-left: 4px solid var(--danger);
|
||||
}
|
||||
|
||||
.timeline-event.kind-budget {
|
||||
border-left: 4px solid var(--warning);
|
||||
}
|
||||
|
||||
.timeline-event.kind-final {
|
||||
border-left: 4px solid var(--accent);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 30px 18px;
|
||||
text-align: center;
|
||||
color: var(--text-soft);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: none;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
padding: 10px 4px 0;
|
||||
}
|
||||
|
||||
.typing-indicator.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-strong);
|
||||
animation: bounce 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(2) {
|
||||
animation-delay: 0.16s;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(3) {
|
||||
animation-delay: 0.32s;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.45;
|
||||
}
|
||||
40% {
|
||||
transform: translateY(-5px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.composer {
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.composer-row {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
#prompt {
|
||||
width: 100%;
|
||||
min-height: 78px;
|
||||
max-height: 240px;
|
||||
resize: vertical;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
color: var(--text);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
#prompt:focus {
|
||||
outline: 2px solid rgba(74, 211, 255, 0.35);
|
||||
border-color: rgba(74, 211, 255, 0.45);
|
||||
}
|
||||
|
||||
.composer-footer {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.composer-hint {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
kbd {
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
body {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.composer {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.composer-footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.composer-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.composer-actions > button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -7,27 +7,81 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MinecraftClient", "Minecraf
|
|||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ConsoleInteractive", "ConsoleInteractive\ConsoleInteractive\ConsoleInteractive\ConsoleInteractive.csproj", "{93DA4D71-EFAD-4493-BE21-A105AF663660}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DebugTools", "DebugTools", "{02313C6C-37F1-D66D-F235-6A4537C03113}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MccMcpStdioHarness", "DebugTools\MccMcpStdioHarness\MccMcpStdioHarness.csproj", "{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MccMcpWebPlayground", "DebugTools\MccMcpWebPlayground\MccMcpWebPlayground.csproj", "{5F620CF6-BC7D-449A-B779-2D51985059C6}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{1E2FACE4-F5CA-4323-9641-740C6A551770}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1E2FACE4-F5CA-4323-9641-740C6A551770}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1E2FACE4-F5CA-4323-9641-740C6A551770}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1E2FACE4-F5CA-4323-9641-740C6A551770}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1E2FACE4-F5CA-4323-9641-740C6A551770}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1E2FACE4-F5CA-4323-9641-740C6A551770}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{1E2FACE4-F5CA-4323-9641-740C6A551770}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1E2FACE4-F5CA-4323-9641-740C6A551770}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1E2FACE4-F5CA-4323-9641-740C6A551770}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1E2FACE4-F5CA-4323-9641-740C6A551770}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1E2FACE4-F5CA-4323-9641-740C6A551770}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1E2FACE4-F5CA-4323-9641-740C6A551770}.Release|x86.Build.0 = Release|Any CPU
|
||||
{93DA4D71-EFAD-4493-BE21-A105AF663660}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{93DA4D71-EFAD-4493-BE21-A105AF663660}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{93DA4D71-EFAD-4493-BE21-A105AF663660}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{93DA4D71-EFAD-4493-BE21-A105AF663660}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{93DA4D71-EFAD-4493-BE21-A105AF663660}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{93DA4D71-EFAD-4493-BE21-A105AF663660}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{93DA4D71-EFAD-4493-BE21-A105AF663660}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{93DA4D71-EFAD-4493-BE21-A105AF663660}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{93DA4D71-EFAD-4493-BE21-A105AF663660}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{93DA4D71-EFAD-4493-BE21-A105AF663660}.Release|x64.Build.0 = Release|Any CPU
|
||||
{93DA4D71-EFAD-4493-BE21-A105AF663660}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{93DA4D71-EFAD-4493-BE21-A105AF663660}.Release|x86.Build.0 = Release|Any CPU
|
||||
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Release|x64.Build.0 = Release|Any CPU
|
||||
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6}.Release|x86.Build.0 = Release|Any CPU
|
||||
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|x64.Build.0 = Release|Any CPU
|
||||
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{5F620CF6-BC7D-449A-B779-2D51985059C6}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{F032D2BB-A0A9-4726-A58F-C02F7EA606D6} = {02313C6C-37F1-D66D-F235-6A4537C03113}
|
||||
{5F620CF6-BC7D-449A-B779-2D51985059C6} = {02313C6C-37F1-D66D-F235-6A4537C03113}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
RESX_ShowErrorsInErrorList = False
|
||||
SolutionGuid = {6DED60F4-9CF4-4DB3-8966-582B2EBE8487}
|
||||
RESX_ShowErrorsInErrorList = False
|
||||
RESX_SortFileContentOnSave = False
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
|
|
|||
272
MinecraftClient/ChatBots/McpServer.cs
Normal file
272
MinecraftClient/ChatBots/McpServer.cs
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
using System;
|
||||
using MinecraftClient.Mapping;
|
||||
using MinecraftClient.Mcp;
|
||||
using MinecraftClient.Scripting;
|
||||
using Tomlet.Attributes;
|
||||
|
||||
namespace MinecraftClient.ChatBots
|
||||
{
|
||||
public class McpServer : ChatBot
|
||||
{
|
||||
public static Configs Config = new();
|
||||
|
||||
[TomlDoNotInlineObject]
|
||||
public class Configs
|
||||
{
|
||||
[NonSerialized]
|
||||
private const string BotName = "McpServer";
|
||||
|
||||
[TomlInlineComment("$ChatBot.McpServer.Enabled$")]
|
||||
public bool Enabled = false;
|
||||
|
||||
[TomlPrecedingComment("$ChatBot.McpServer.Transport$")]
|
||||
public MccMcpTransportConfig Transport = new();
|
||||
|
||||
[TomlPrecedingComment("$ChatBot.McpServer.Capabilities$")]
|
||||
public MccMcpCapabilityToggles Capabilities = new();
|
||||
|
||||
public void OnSettingUpdate()
|
||||
{
|
||||
Transport ??= new MccMcpTransportConfig();
|
||||
Capabilities ??= new MccMcpCapabilityToggles();
|
||||
|
||||
if (Transport.Port is < 1 or > 65535)
|
||||
Transport.Port = 33333;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Transport.BindHost))
|
||||
Transport.BindHost = "127.0.0.1";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Transport.Route))
|
||||
Transport.Route = "/mcp";
|
||||
|
||||
if (!Transport.Route.StartsWith('/'))
|
||||
Transport.Route = "/" + Transport.Route;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Transport.AuthTokenEnvVar))
|
||||
Transport.AuthTokenEnvVar = "MCC_MCP_AUTH_TOKEN";
|
||||
}
|
||||
}
|
||||
|
||||
private MccEmbeddedMcpHost? host;
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
Config.OnSettingUpdate();
|
||||
}
|
||||
|
||||
public override void AfterGameJoined()
|
||||
{
|
||||
if (!Config.Enabled)
|
||||
return;
|
||||
|
||||
ClearStores();
|
||||
|
||||
MccMcpConfig mcpConfig = new()
|
||||
{
|
||||
Enabled = Config.Enabled,
|
||||
Transport = Config.Transport,
|
||||
Capabilities = Config.Capabilities
|
||||
};
|
||||
|
||||
host ??= new MccEmbeddedMcpHost(mcpConfig, new MccMcpCapabilities(() => Config.Capabilities));
|
||||
|
||||
if (host.IsRunning)
|
||||
return;
|
||||
|
||||
LogToConsole(Translations.bot_mcpserver_starting);
|
||||
if (!host.Start(out string? error))
|
||||
{
|
||||
if (error == "missing_auth_token")
|
||||
LogToConsole(string.Format(Translations.bot_mcpserver_missing_auth_token, Config.Transport.AuthTokenEnvVar));
|
||||
LogToConsole(string.Format(Translations.bot_mcpserver_start_failed, error ?? "unknown"));
|
||||
return;
|
||||
}
|
||||
|
||||
LogToConsole(string.Format(Translations.bot_mcpserver_started, host.Endpoint));
|
||||
}
|
||||
|
||||
public override bool OnDisconnect(DisconnectReason reason, string message)
|
||||
{
|
||||
MccObservedStateStore.AddRecentEvent("disconnect", new
|
||||
{
|
||||
reason = reason.ToString(),
|
||||
message
|
||||
});
|
||||
StopHost();
|
||||
ClearStores();
|
||||
return false;
|
||||
}
|
||||
|
||||
public override void OnUnload()
|
||||
{
|
||||
StopHost();
|
||||
ClearStores();
|
||||
}
|
||||
|
||||
public override void GetText(string text, string? json)
|
||||
{
|
||||
string clean = GetVerbatim(text);
|
||||
if (string.IsNullOrWhiteSpace(clean))
|
||||
return;
|
||||
|
||||
string kind = "system";
|
||||
string? sender = null;
|
||||
string? message = null;
|
||||
|
||||
string parsedMessage = string.Empty;
|
||||
string parsedSender = string.Empty;
|
||||
if (IsPrivateMessage(clean, ref parsedMessage, ref parsedSender))
|
||||
{
|
||||
kind = "private";
|
||||
sender = parsedSender;
|
||||
message = parsedMessage;
|
||||
}
|
||||
else if (IsChatMessage(clean, ref parsedMessage, ref parsedSender))
|
||||
{
|
||||
kind = "chat";
|
||||
sender = parsedSender;
|
||||
message = parsedMessage;
|
||||
}
|
||||
|
||||
MccObservedStateStore.AddChatHistoryEntry(new MccChatHistoryEntry
|
||||
{
|
||||
TimestampUtc = DateTimeOffset.UtcNow,
|
||||
Kind = kind,
|
||||
Text = clean,
|
||||
Sender = sender,
|
||||
Message = message,
|
||||
Json = json
|
||||
});
|
||||
}
|
||||
|
||||
public override void OnTimeUpdate(long WorldAge, long TimeOfDay)
|
||||
{
|
||||
MccObservedStateStore.SetTime(WorldAge, TimeOfDay);
|
||||
}
|
||||
|
||||
public override void OnRainLevelChange(float level)
|
||||
{
|
||||
MccObservedStateStore.SetRainLevel(level);
|
||||
MccObservedStateStore.AddRecentEvent("weather_rain", new { level });
|
||||
}
|
||||
|
||||
public override void OnThunderLevelChange(float level)
|
||||
{
|
||||
MccObservedStateStore.SetThunderLevel(level);
|
||||
MccObservedStateStore.AddRecentEvent("weather_thunder", new { level });
|
||||
}
|
||||
|
||||
public override void OnDeath()
|
||||
{
|
||||
MccObservedStateStore.AddRecentEvent("death");
|
||||
}
|
||||
|
||||
public override void OnRespawn()
|
||||
{
|
||||
MccObservedStateStore.AddRecentEvent("respawn");
|
||||
}
|
||||
|
||||
public override void OnPlayerJoin(Guid uuid, string name)
|
||||
{
|
||||
MccObservedStateStore.AddRecentEvent("player_join", new
|
||||
{
|
||||
uuid,
|
||||
name
|
||||
});
|
||||
}
|
||||
|
||||
public override void OnPlayerLeave(Guid uuid, string? name)
|
||||
{
|
||||
MccObservedStateStore.AddRecentEvent("player_leave", new
|
||||
{
|
||||
uuid,
|
||||
name
|
||||
});
|
||||
}
|
||||
|
||||
public override void OnInventoryOpen(int inventoryId)
|
||||
{
|
||||
MccObservedStateStore.AddRecentEvent("inventory_open", new { inventoryId });
|
||||
}
|
||||
|
||||
public override void OnInventoryClose(int inventoryId)
|
||||
{
|
||||
MccObservedStateStore.AddRecentEvent("inventory_close", new { inventoryId });
|
||||
}
|
||||
|
||||
public override void OnTitle(int action, string titletext, string subtitletext, string actionbartext, int fadein, int stay, int fadeout, string json)
|
||||
{
|
||||
if (action == 2)
|
||||
{
|
||||
MccObservedStateStore.AddRecentEvent("actionbar", new
|
||||
{
|
||||
action,
|
||||
text = actionbartext,
|
||||
fadein,
|
||||
stay,
|
||||
fadeout,
|
||||
json
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (action is 0 or 1)
|
||||
{
|
||||
MccObservedStateStore.AddRecentEvent("title", new
|
||||
{
|
||||
action,
|
||||
titleText = titletext,
|
||||
subtitleText = subtitletext,
|
||||
fadein,
|
||||
stay,
|
||||
fadeout,
|
||||
json
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public override void OnBlockBreakAnimation(Entity entity, Location location, byte stage)
|
||||
{
|
||||
MccObservedStateStore.AddRecentEvent("block_break_animation", new
|
||||
{
|
||||
entityId = entity.ID,
|
||||
entityType = entity.Type.ToString(),
|
||||
stage,
|
||||
location = new
|
||||
{
|
||||
x = location.X,
|
||||
y = location.Y,
|
||||
z = location.Z
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public override void OnEntityAnimation(Entity entity, byte animation)
|
||||
{
|
||||
MccObservedStateStore.AddRecentEvent("entity_animation", new
|
||||
{
|
||||
entityId = entity.ID,
|
||||
entityType = entity.Type.ToString(),
|
||||
animation,
|
||||
name = entity.Name,
|
||||
customName = entity.CustomName
|
||||
});
|
||||
}
|
||||
|
||||
private void StopHost()
|
||||
{
|
||||
if (host is null || !host.IsRunning)
|
||||
return;
|
||||
|
||||
if (host.Stop(out string? error))
|
||||
LogToConsole(Translations.bot_mcpserver_stopped);
|
||||
else
|
||||
LogToConsole(string.Format(Translations.bot_mcpserver_stop_failed, error ?? "unknown"));
|
||||
}
|
||||
|
||||
private static void ClearStores()
|
||||
{
|
||||
MccObservedStateStore.ClearAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -92,6 +92,7 @@ namespace MinecraftClient.Commands
|
|||
return r.SetAndReturn(Status.FailNeedTerrain);
|
||||
|
||||
handler.UpdateLocation(handler.GetCurrentLocation(), direction);
|
||||
handler.SendLocationUpdate();
|
||||
return r.SetAndReturn(Status.Done, "Looking " + direction.ToString());
|
||||
}
|
||||
|
||||
|
|
@ -102,6 +103,7 @@ namespace MinecraftClient.Commands
|
|||
return r.SetAndReturn(Status.FailNeedTerrain);
|
||||
|
||||
handler.UpdateLocation(handler.GetCurrentLocation(), yaw, pitch);
|
||||
handler.SendLocationUpdate();
|
||||
return r.SetAndReturn(Status.Done, string.Format(Translations.cmd_look_at, yaw.ToString("0.00"), pitch.ToString("0.00")));
|
||||
}
|
||||
|
||||
|
|
@ -113,6 +115,7 @@ namespace MinecraftClient.Commands
|
|||
|
||||
Location current = handler.GetCurrentLocation();
|
||||
handler.UpdateLocation(current, location);
|
||||
handler.SendLocationUpdate();
|
||||
return r.SetAndReturn(Status.Done, string.Format(Translations.cmd_look_block, location));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,8 @@ namespace MinecraftClient
|
|||
|
||||
// Entity handling
|
||||
private readonly Dictionary<int, Entity> entities = new();
|
||||
private readonly Lock signDataLock = new();
|
||||
private readonly Dictionary<(int x, int y, int z), (string material, string typeLabel, string[] frontText, string[] backText, bool isWaxed)> knownSigns = new();
|
||||
|
||||
// server TPS
|
||||
private long lastAge = 0;
|
||||
|
|
@ -201,6 +203,21 @@ namespace MinecraftClient
|
|||
public void GetCookie(string key, out byte[]? data) => Cookies.TryGetValue(key, out data);
|
||||
public void SetCookie(string key, byte[] data) => Cookies[key] = data;
|
||||
public void DeleteCookie(string key) => Cookies.Remove(key, out var data);
|
||||
public (Location location, string material, string typeLabel, string[] frontText, string[] backText, bool isWaxed)[] GetKnownSigns()
|
||||
{
|
||||
lock (signDataLock)
|
||||
{
|
||||
return knownSigns
|
||||
.Select(pair => (
|
||||
location: new Location(pair.Key.x, pair.Key.y, pair.Key.z),
|
||||
material: pair.Value.material,
|
||||
typeLabel: pair.Value.typeLabel,
|
||||
frontText: (string[])pair.Value.frontText.Clone(),
|
||||
backText: (string[])pair.Value.backText.Clone(),
|
||||
isWaxed: pair.Value.isWaxed))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
TcpClient client = null!;
|
||||
IMinecraftCom handler = null!;
|
||||
|
|
@ -513,6 +530,7 @@ namespace MinecraftClient
|
|||
physicsInput.Reset();
|
||||
world.Clear();
|
||||
entities.Clear();
|
||||
ClearKnownSigns();
|
||||
ClearInventories();
|
||||
}
|
||||
|
||||
|
|
@ -545,6 +563,7 @@ namespace MinecraftClient
|
|||
if (Config.ChatBot.TelegramBridge.Enabled) { BotLoad(new TelegramBridge()); }
|
||||
if (Config.ChatBot.ItemsCollector.Enabled) { BotLoad(new ItemsCollector()); }
|
||||
if (Config.ChatBot.DiscordRpc.Enabled) { BotLoad(new DiscordRpc()); }
|
||||
if (Config.ChatBot.McpServer.Enabled) { BotLoad(new McpServer()); }
|
||||
if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("MCC_FILE_INPUT")))
|
||||
BotLoad(new FileInputBot());
|
||||
}
|
||||
|
|
@ -797,6 +816,7 @@ namespace MinecraftClient
|
|||
handler.Dispose();
|
||||
|
||||
world.Clear();
|
||||
ClearKnownSigns();
|
||||
|
||||
if (timeoutdetector is not null)
|
||||
{
|
||||
|
|
@ -1791,6 +1811,26 @@ namespace MinecraftClient
|
|||
changedSlots.Add(new Tuple<short, Item?>((short)slotId, null));
|
||||
}
|
||||
|
||||
private static bool IsServerManagedOutputSlot(Container inventory, int slotId)
|
||||
{
|
||||
return (inventory.Type, slotId) switch
|
||||
{
|
||||
(ContainerType.PlayerInventory, 0) => true,
|
||||
(ContainerType.Crafting, 0) => true,
|
||||
(ContainerType.Anvil, 2) => true,
|
||||
(ContainerType.BlastFurnace, 2) => true,
|
||||
(ContainerType.Furnace, 2) => true,
|
||||
(ContainerType.Smoker, 2) => true,
|
||||
(ContainerType.Grindstone, 2) => true,
|
||||
(ContainerType.Cartography, 2) => true,
|
||||
(ContainerType.Merchant, 2) => true,
|
||||
(ContainerType.Stonecutter, 1) => true,
|
||||
(ContainerType.Loom, 3) => true,
|
||||
(ContainerType.SmightingTable, 3) => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Click a slot in the specified window
|
||||
/// </summary>
|
||||
|
|
@ -1817,8 +1857,9 @@ namespace MinecraftClient
|
|||
// Check if cursor have item (slot -1)
|
||||
if (playerInventory.Items.ContainsKey(-1))
|
||||
{
|
||||
// When item on cursor and clicking slot 0, nothing will happen
|
||||
if (slotId == 0) break;
|
||||
// Result slots are server-managed and cannot accept cursor items directly.
|
||||
if (IsServerManagedOutputSlot(inventory, slotId))
|
||||
break;
|
||||
|
||||
// Check target slot also have item?
|
||||
if (inventory.Items.ContainsKey(slotId))
|
||||
|
|
@ -1864,8 +1905,8 @@ namespace MinecraftClient
|
|||
// Check target slot have item?
|
||||
if (inventory.Items.ContainsKey(slotId))
|
||||
{
|
||||
// When taking item from slot 0, server will update us
|
||||
if (slotId == 0) break;
|
||||
if (IsServerManagedOutputSlot(inventory, slotId))
|
||||
break;
|
||||
|
||||
// Put target slot item to cursor
|
||||
playerInventory.Items[-1] = inventory.Items[slotId];
|
||||
|
|
@ -1879,8 +1920,8 @@ namespace MinecraftClient
|
|||
// Check if cursor have item (slot -1)
|
||||
if (playerInventory.Items.ContainsKey(-1))
|
||||
{
|
||||
// When item on cursor and clicking slot 0, nothing will happen
|
||||
if (slotId == 0) break;
|
||||
if (IsServerManagedOutputSlot(inventory, slotId))
|
||||
break;
|
||||
|
||||
// Check target slot have item?
|
||||
if (inventory.Items.ContainsKey(slotId))
|
||||
|
|
@ -1916,10 +1957,8 @@ namespace MinecraftClient
|
|||
// Check target slot have item?
|
||||
if (inventory.Items.ContainsKey(slotId))
|
||||
{
|
||||
if (slotId == 0)
|
||||
if (IsServerManagedOutputSlot(inventory, slotId))
|
||||
{
|
||||
// no matter how many item in slot 0, only 1 will be taken out
|
||||
// Also server will update us
|
||||
break;
|
||||
}
|
||||
if (inventory.Items[slotId].Count == 1)
|
||||
|
|
@ -1955,7 +1994,8 @@ namespace MinecraftClient
|
|||
break;
|
||||
case WindowActionType.ShiftClick:
|
||||
case WindowActionType.ShiftRightClick:
|
||||
if (slotId == 0) break;
|
||||
if (IsServerManagedOutputSlot(inventory, slotId))
|
||||
break;
|
||||
if (item is not null)
|
||||
{
|
||||
/* Target slot have item */
|
||||
|
|
@ -2753,6 +2793,41 @@ namespace MinecraftClient
|
|||
return handler.SendHeldItemChange(slot);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drop the currently selected hotbar item like a real player pressing Q or Ctrl+Q.
|
||||
/// </summary>
|
||||
/// <param name="dropEntireStack">TRUE to drop the whole stack, FALSE to drop one item</param>
|
||||
/// <returns>TRUE if the packet was sent</returns>
|
||||
public bool DropSelectedItem(bool dropEntireStack)
|
||||
{
|
||||
if (InvokeRequired)
|
||||
return InvokeOnMainThread(() => DropSelectedItem(dropEntireStack));
|
||||
|
||||
Location actionLocation = GetCurrentLocation().ToFloor();
|
||||
int status = dropEntireStack ? 3 : 4;
|
||||
bool sent = handler.SendPlayerDigging(status, actionLocation, Direction.Down, sequenceId++);
|
||||
if (sent)
|
||||
ApplySelectedItemDropPrediction(dropEntireStack);
|
||||
|
||||
return sent;
|
||||
}
|
||||
|
||||
private void ApplySelectedItemDropPrediction(bool dropEntireStack)
|
||||
{
|
||||
if (!inventories.TryGetValue(0, out Container? playerInventory))
|
||||
return;
|
||||
|
||||
int selectedSlotId = CurrentSlot + 36;
|
||||
if (!playerInventory.Items.TryGetValue(selectedSlotId, out Item? heldItem) || heldItem.IsEmpty)
|
||||
return;
|
||||
|
||||
if (dropEntireStack || heldItem.Count <= 1)
|
||||
playerInventory.Items.Remove(selectedSlotId);
|
||||
else heldItem.Count--;
|
||||
|
||||
DispatchBotEvent(bot => bot.OnInventoryUpdate(0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update sign text
|
||||
/// </summary>
|
||||
|
|
@ -2988,6 +3063,7 @@ namespace MinecraftClient
|
|||
}
|
||||
|
||||
entities.Clear();
|
||||
ClearKnownSigns();
|
||||
ClearInventories();
|
||||
DispatchBotEvent(bot => bot.OnRespawn());
|
||||
}
|
||||
|
|
@ -3164,6 +3240,21 @@ namespace MinecraftClient
|
|||
UpdateLocation(location, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send the current player position and look angles to the server.
|
||||
/// </summary>
|
||||
/// <returns>TRUE if the update packet was sent</returns>
|
||||
public bool SendLocationUpdate()
|
||||
{
|
||||
if (InvokeRequired)
|
||||
return InvokeOnMainThread(SendLocationUpdate);
|
||||
|
||||
Location current = GetCurrentLocation();
|
||||
bool onGround = physicsInitialized ? playerPhysics.OnGround : Movement.IsOnGround(world, current);
|
||||
bool horizontalCollision = physicsInitialized && playerPhysics.HorizontalCollision;
|
||||
return handler.SendLocationUpdate(current, onGround, horizontalCollision, _yaw, _pitch);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when the server sends a new player location,
|
||||
/// or if a ChatBot whishes to update the player's location.
|
||||
|
|
@ -4336,9 +4427,16 @@ namespace MinecraftClient
|
|||
public void OnBlockChange(Location location, Block block)
|
||||
{
|
||||
world.SetBlock(location, block);
|
||||
if (!IsSignMaterial(block.Type))
|
||||
RemoveKnownSign(location);
|
||||
DispatchBotEvent(bot => bot.OnBlockChange(location, block));
|
||||
}
|
||||
|
||||
public void OnBlockEntityData(Location location, Dictionary<string, object>? nbt)
|
||||
{
|
||||
UpdateKnownSign(location, nbt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when "AutoComplete" completes.
|
||||
/// </summary>
|
||||
|
|
@ -4457,6 +4555,137 @@ namespace MinecraftClient
|
|||
return handler.ClickContainerButton(windowId, buttonId);
|
||||
}
|
||||
|
||||
private void ClearKnownSigns()
|
||||
{
|
||||
lock (signDataLock)
|
||||
{
|
||||
knownSigns.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveKnownSign(Location location)
|
||||
{
|
||||
var key = ToBlockKey(location);
|
||||
lock (signDataLock)
|
||||
{
|
||||
knownSigns.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateKnownSign(Location location, Dictionary<string, object>? nbt)
|
||||
{
|
||||
var key = ToBlockKey(location);
|
||||
var block = world.GetBlock(new Location(key.x, key.y, key.z));
|
||||
if (!IsSignMaterial(block.Type) || !TryExtractSignText(nbt, out string[] frontText, out string[] backText, out bool isWaxed))
|
||||
{
|
||||
lock (signDataLock)
|
||||
{
|
||||
knownSigns.Remove(key);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
lock (signDataLock)
|
||||
{
|
||||
knownSigns[key] = (block.Type.ToString(), block.GetTypeString(), frontText, backText, isWaxed);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryExtractSignText(Dictionary<string, object>? nbt, out string[] frontText, out string[] backText, out bool isWaxed)
|
||||
{
|
||||
frontText = ExtractSignLines(nbt, "front_text");
|
||||
backText = ExtractSignLines(nbt, "back_text");
|
||||
if (frontText.Length == 0 && backText.Length == 0)
|
||||
frontText = ExtractLegacySignLines(nbt);
|
||||
|
||||
isWaxed = nbt is not null
|
||||
&& nbt.TryGetValue("is_waxed", out object? waxedValue)
|
||||
&& waxedValue is bool waxed
|
||||
&& waxed;
|
||||
return frontText.Length > 0 || backText.Length > 0;
|
||||
}
|
||||
|
||||
private static string[] ExtractSignLines(Dictionary<string, object>? nbt, string sideKey)
|
||||
{
|
||||
if (nbt is null
|
||||
|| !nbt.TryGetValue(sideKey, out object? sideValue)
|
||||
|| sideValue is not Dictionary<string, object> sideData
|
||||
|| !sideData.TryGetValue("messages", out object? messagesValue)
|
||||
|| messagesValue is not object[] messages)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return messages
|
||||
.Take(4)
|
||||
.Select(ConvertSignMessage)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string[] ExtractLegacySignLines(Dictionary<string, object>? nbt)
|
||||
{
|
||||
if (nbt is null)
|
||||
return [];
|
||||
|
||||
List<string> lines = new(4);
|
||||
for (int i = 1; i <= 4; i++)
|
||||
{
|
||||
if (nbt.TryGetValue($"Text{i}", out object? value))
|
||||
lines.Add(ConvertSignMessage(value));
|
||||
}
|
||||
|
||||
return lines.ToArray();
|
||||
}
|
||||
|
||||
private static string ConvertSignMessage(object? value)
|
||||
{
|
||||
try
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => string.Empty,
|
||||
string text => ParseMaybeJsonText(text),
|
||||
Dictionary<string, object> nbt => ChatParser.ParseText(nbt),
|
||||
object[] items => string.Concat(items.Select(ConvertSignMessage)),
|
||||
_ => value.ToString() ?? string.Empty
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return value?.ToString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ParseMaybeJsonText(string text)
|
||||
{
|
||||
string trimmed = text.Trim();
|
||||
if ((trimmed.StartsWith("{", StringComparison.Ordinal) && trimmed.EndsWith("}", StringComparison.Ordinal))
|
||||
|| (trimmed.StartsWith("[", StringComparison.Ordinal) && trimmed.EndsWith("]", StringComparison.Ordinal)))
|
||||
{
|
||||
try
|
||||
{
|
||||
return ChatParser.ParseText(trimmed);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
private static bool IsSignMaterial(Material material)
|
||||
{
|
||||
return material.ToString().Contains("Sign", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static (int x, int y, int z) ToBlockKey(Location location)
|
||||
{
|
||||
Location blockLocation = location.ToFloor();
|
||||
return ((int)blockLocation.X, (int)blockLocation.Y, (int)blockLocation.Z);
|
||||
}
|
||||
|
||||
private static bool SupportsRecipeBook(ContainerType containerType)
|
||||
{
|
||||
return containerType switch
|
||||
|
|
|
|||
66
MinecraftClient/Mcp/IMccMcpCapabilities.cs
Normal file
66
MinecraftClient/Mcp/IMccMcpCapabilities.cs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
namespace MinecraftClient.Mcp;
|
||||
|
||||
public interface IMccMcpCapabilities
|
||||
{
|
||||
MccMcpResult GetSessionStatus();
|
||||
MccMcpResult GetServerInfo();
|
||||
MccMcpResult GetPlayerState();
|
||||
MccMcpResult GetWorldState();
|
||||
MccMcpResult GetChunkStatus(double? x, double? y, double? z);
|
||||
MccMcpResult RaycastBlock(double maxDistance, bool includeNeighbors);
|
||||
MccMcpResult PreviewPath(double x, double y, double z, bool allowUnsafe, int maxOffset, int minOffset, int timeoutMs, int maxWaypoints);
|
||||
MccMcpResult GetPlayersList();
|
||||
MccMcpResult GetPlayersDetailed(bool includeSelf, bool includeCoordinates);
|
||||
MccMcpResult GetPlayerStats();
|
||||
MccMcpResult GetStatusEffects();
|
||||
MccMcpResult GetRecentEvents(long afterId, int maxCount, string? typeFilter);
|
||||
MccMcpResult GetLoadedBots();
|
||||
MccMcpResult GetChatHistory(int maxCount, bool includeJson);
|
||||
MccMcpResult GetInternalCommands();
|
||||
MccMcpResult GetMaterialsList(string? filter, int maxCount);
|
||||
MccMcpResult GetBlockTypesList(string? filter, int maxCount);
|
||||
MccMcpResult GetEntityTypesList(string? filter, int maxCount);
|
||||
MccMcpResult SendChat(string text);
|
||||
MccMcpResult QuitClient();
|
||||
MccMcpResult DisconnectClient();
|
||||
MccMcpResult Respawn();
|
||||
MccMcpResult RunInternalCommand(string command);
|
||||
MccMcpResult PlayAnimation(string hand);
|
||||
MccMcpResult ToggleSneak(bool enabled);
|
||||
MccMcpResult ToggleSprint(bool enabled);
|
||||
MccMcpResult UseItemOnHand();
|
||||
MccMcpResult ChangeHotbarSlot(int slot);
|
||||
MccMcpResult SelectHotbarItem(string itemType, bool preferLowestSlot);
|
||||
MccMcpResult UseItemOnBlock(double x, double y, double z);
|
||||
MccMcpResult DigBlock(double x, double y, double z, double durationSeconds);
|
||||
MccMcpResult PlaceBlock(int x, int y, int z, string face, string hand, bool lookAtBlock);
|
||||
MccMcpResult InteractEntity(int entityId, string interaction, string hand);
|
||||
MccMcpResult AttackEntity(int entityId);
|
||||
MccMcpResult ScanNearbyBlocks(int radius, int maxCount, string? materialFilter);
|
||||
MccMcpResult FindBlocks(string? query, int radius, int maxCount, bool exactMatch);
|
||||
MccMcpResult IsPlayerNearby(string? playerName, double radius, bool includeSelf);
|
||||
MccMcpResult LocatePlayer(string playerName, bool includeSelf);
|
||||
MccMcpResult FindNearestEntity(string? typeFilter, string? nameFilter, double radius, bool includePlayers);
|
||||
MccMcpResult CanReachPosition(double x, double y, double z, bool allowUnsafe, int maxOffset, int minOffset, int timeoutMs);
|
||||
MccMcpResult MoveTo(double x, double y, double z, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs);
|
||||
MccMcpResult MoveToPlayer(string playerName, bool allowUnsafe, bool allowDirectTeleport, int maxOffset, int minOffset, int timeoutMs);
|
||||
MccMcpResult LookAt(double x, double y, double z);
|
||||
MccMcpResult LookDirection(string direction);
|
||||
MccMcpResult LookAngles(float yaw, float pitch);
|
||||
MccMcpResult ListInventories();
|
||||
MccMcpResult GetInventorySnapshot(int inventoryId);
|
||||
MccMcpResult SearchInventories(string query, int maxCount, bool exactMatch, bool includeContainers);
|
||||
MccMcpResult OpenContainerAt(int x, int y, int z, int timeoutMs, bool closeCurrent);
|
||||
MccMcpResult CloseContainer(int inventoryId, int timeoutMs);
|
||||
MccMcpResult InventoryWindowAction(int inventoryId, int slotId, string actionType);
|
||||
MccMcpResult DropInventoryItem(string itemType, int count, int inventoryId, bool preferStack);
|
||||
MccMcpResult DepositContainerItem(string itemType, int count, int inventoryId, bool preferLargestStack);
|
||||
MccMcpResult WithdrawContainerItem(string itemType, int count, int inventoryId, bool preferLargestStack);
|
||||
MccMcpResult QueryEntities(int maxCount);
|
||||
MccMcpResult ListEntities(int maxCount, string? typeFilter, double radius);
|
||||
MccMcpResult GetEntityInfo(int entityId, bool includeMetadata, bool includeEquipment, bool includeEffects);
|
||||
MccMcpResult FindSigns(string text, bool exactMatch, int radius, int maxCount, bool includeBackText);
|
||||
MccMcpResult ListItemEntities(string? itemType, double radius, int maxCount);
|
||||
MccMcpResult PickupItems(string itemType, double radius, int maxItems, bool allowUnsafe, int timeoutMs);
|
||||
MccMcpResult GetWorldBlockAt(int x, int y, int z);
|
||||
}
|
||||
135
MinecraftClient/Mcp/MccEmbeddedMcpHost.cs
Normal file
135
MinecraftClient/Mcp/MccEmbeddedMcpHost.cs
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
using System;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace MinecraftClient.Mcp;
|
||||
|
||||
public sealed class MccEmbeddedMcpHost
|
||||
{
|
||||
private readonly MccMcpConfig config;
|
||||
private readonly IMccMcpCapabilities capabilities;
|
||||
private readonly object stateLock = new();
|
||||
private WebApplication? app;
|
||||
|
||||
public MccEmbeddedMcpHost(MccMcpConfig config, IMccMcpCapabilities capabilities)
|
||||
{
|
||||
this.config = config;
|
||||
this.capabilities = capabilities;
|
||||
}
|
||||
|
||||
public bool IsRunning
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (stateLock)
|
||||
{
|
||||
return app is not null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string Endpoint => $"http://{config.Transport.BindHost}:{config.Transport.Port}{NormalizeRoute(config.Transport.Route)}";
|
||||
|
||||
public bool Start(out string? error)
|
||||
{
|
||||
lock (stateLock)
|
||||
{
|
||||
error = null;
|
||||
if (app is not null)
|
||||
return true;
|
||||
|
||||
string route = NormalizeRoute(config.Transport.Route);
|
||||
string bindHost = string.IsNullOrWhiteSpace(config.Transport.BindHost) ? "127.0.0.1" : config.Transport.BindHost.Trim();
|
||||
if (config.Transport.Port is < 1 or > 65535)
|
||||
{
|
||||
error = "invalid_port";
|
||||
return false;
|
||||
}
|
||||
|
||||
string? requiredToken = null;
|
||||
if (config.Transport.RequireAuthToken)
|
||||
{
|
||||
requiredToken = Environment.GetEnvironmentVariable(config.Transport.AuthTokenEnvVar);
|
||||
if (string.IsNullOrWhiteSpace(requiredToken))
|
||||
{
|
||||
error = "missing_auth_token";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddFilter(_ => false);
|
||||
builder.Services.AddSingleton(capabilities);
|
||||
builder.Services.AddSingleton(config);
|
||||
builder.Services.AddSingleton<MccMcpGuidanceProvider>();
|
||||
builder.Services.AddMcpServer()
|
||||
.WithHttpTransport()
|
||||
.WithTools<MccMcpToolSet>()
|
||||
.WithPrompts<MccMcpPromptSet>();
|
||||
|
||||
builder.WebHost.UseUrls($"http://{bindHost}:{config.Transport.Port}");
|
||||
WebApplication builtApp = builder.Build();
|
||||
|
||||
if (config.Transport.RequireAuthToken)
|
||||
{
|
||||
builtApp.Use(async (context, next) =>
|
||||
{
|
||||
if (context.Request.Path.StartsWithSegments(route, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string auth = context.Request.Headers.Authorization.ToString();
|
||||
if (!auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
|
||||
|| !string.Equals(auth[7..], requiredToken, StringComparison.Ordinal))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
await context.Response.WriteAsync("Unauthorized");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await next();
|
||||
});
|
||||
}
|
||||
|
||||
builtApp.MapMcp(route);
|
||||
builtApp.StartAsync().GetAwaiter().GetResult();
|
||||
app = builtApp;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Stop(out string? error)
|
||||
{
|
||||
lock (stateLock)
|
||||
{
|
||||
error = null;
|
||||
if (app is null)
|
||||
return true;
|
||||
|
||||
try
|
||||
{
|
||||
app.StopAsync().GetAwaiter().GetResult();
|
||||
app.DisposeAsync().AsTask().GetAwaiter().GetResult();
|
||||
app = null;
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
error = "stop_failed";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeRoute(string route)
|
||||
{
|
||||
string normalized = string.IsNullOrWhiteSpace(route) ? "/mcp" : route.Trim();
|
||||
if (!normalized.StartsWith('/'))
|
||||
normalized = '/' + normalized;
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
3259
MinecraftClient/Mcp/MccMcpCapabilities.cs
Normal file
3259
MinecraftClient/Mcp/MccMcpCapabilities.cs
Normal file
File diff suppressed because it is too large
Load diff
51
MinecraftClient/Mcp/MccMcpChatHistory.cs
Normal file
51
MinecraftClient/Mcp/MccMcpChatHistory.cs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using MinecraftClient.Scripting;
|
||||
|
||||
namespace MinecraftClient.Mcp;
|
||||
|
||||
public sealed class MccMcpChatHistoryEntry
|
||||
{
|
||||
public required DateTimeOffset TimestampUtc { get; init; }
|
||||
public required string Kind { get; init; }
|
||||
public required string Text { get; init; }
|
||||
public string? Sender { get; init; }
|
||||
public string? Message { get; init; }
|
||||
public string? Json { get; init; }
|
||||
}
|
||||
|
||||
public static class MccMcpChatHistoryStore
|
||||
{
|
||||
public static void Add(MccMcpChatHistoryEntry entry)
|
||||
{
|
||||
MccObservedStateStore.AddChatHistoryEntry(new MccChatHistoryEntry
|
||||
{
|
||||
TimestampUtc = entry.TimestampUtc,
|
||||
Kind = entry.Kind,
|
||||
Text = entry.Text,
|
||||
Sender = entry.Sender,
|
||||
Message = entry.Message,
|
||||
Json = entry.Json
|
||||
});
|
||||
}
|
||||
|
||||
public static MccMcpChatHistoryEntry[] GetLatest(int maxCount)
|
||||
{
|
||||
return MccObservedStateStore.GetLatestChatHistory(maxCount)
|
||||
.Select(entry => new MccMcpChatHistoryEntry
|
||||
{
|
||||
TimestampUtc = entry.TimestampUtc,
|
||||
Kind = entry.Kind,
|
||||
Text = entry.Text,
|
||||
Sender = entry.Sender,
|
||||
Message = entry.Message,
|
||||
Json = entry.Json
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
MccObservedStateStore.ClearChatHistory();
|
||||
}
|
||||
}
|
||||
46
MinecraftClient/Mcp/MccMcpConfig.cs
Normal file
46
MinecraftClient/Mcp/MccMcpConfig.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
using Tomlet.Attributes;
|
||||
|
||||
namespace MinecraftClient.Mcp;
|
||||
|
||||
public sealed class MccMcpConfig
|
||||
{
|
||||
public bool Enabled { get; set; }
|
||||
public MccMcpTransportConfig Transport { get; set; } = new();
|
||||
public MccMcpCapabilityToggles Capabilities { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class MccMcpTransportConfig
|
||||
{
|
||||
[TomlInlineComment("$ChatBot.McpServer.Transport.BindHost$")]
|
||||
public string BindHost { get; set; } = "127.0.0.1";
|
||||
|
||||
[TomlInlineComment("$ChatBot.McpServer.Transport.Port$")]
|
||||
public int Port { get; set; } = 33333;
|
||||
|
||||
[TomlInlineComment("$ChatBot.McpServer.Transport.Route$")]
|
||||
public string Route { get; set; } = "/mcp";
|
||||
|
||||
[TomlInlineComment("$ChatBot.McpServer.Transport.RequireAuthToken$")]
|
||||
public bool RequireAuthToken { get; set; }
|
||||
|
||||
[TomlInlineComment("$ChatBot.McpServer.Transport.AuthTokenEnvVar$")]
|
||||
public string AuthTokenEnvVar { get; set; } = "MCC_MCP_AUTH_TOKEN";
|
||||
}
|
||||
|
||||
public sealed class MccMcpCapabilityToggles
|
||||
{
|
||||
[TomlInlineComment("$ChatBot.McpServer.Capabilities.SessionStatus$")]
|
||||
public bool SessionStatus { get; set; } = true;
|
||||
|
||||
[TomlInlineComment("$ChatBot.McpServer.Capabilities.ChatAndCommands$")]
|
||||
public bool ChatAndCommands { get; set; } = true;
|
||||
|
||||
[TomlInlineComment("$ChatBot.McpServer.Capabilities.Movement$")]
|
||||
public bool Movement { get; set; } = true;
|
||||
|
||||
[TomlInlineComment("$ChatBot.McpServer.Capabilities.Inventory$")]
|
||||
public bool Inventory { get; set; } = true;
|
||||
|
||||
[TomlInlineComment("$ChatBot.McpServer.Capabilities.EntityWorld$")]
|
||||
public bool EntityWorld { get; set; } = true;
|
||||
}
|
||||
263
MinecraftClient/Mcp/MccMcpGuidanceProvider.cs
Normal file
263
MinecraftClient/Mcp/MccMcpGuidanceProvider.cs
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace MinecraftClient.Mcp;
|
||||
|
||||
public sealed class MccMcpGuidanceProvider
|
||||
{
|
||||
private const string EmbeddedPromptResourceSuffix = "MccMcpOperatorPrompt.md";
|
||||
private const string BestPracticesHeading = "## Best Practices";
|
||||
private const string ExampleScenariosHeading = "## Example Scenarios";
|
||||
|
||||
private readonly MccMcpConfig config;
|
||||
private readonly Lazy<GuidanceDocument> guidanceDocument;
|
||||
|
||||
public MccMcpGuidanceProvider(MccMcpConfig config)
|
||||
{
|
||||
this.config = config;
|
||||
guidanceDocument = new Lazy<GuidanceDocument>(LoadGuidanceDocument);
|
||||
}
|
||||
|
||||
public string PromptName => "mcc_operator_prompt";
|
||||
|
||||
public string SkillName => "mcc-mcp-operator";
|
||||
|
||||
public string GetSystemPrompt()
|
||||
{
|
||||
GuidanceDocument document = guidanceDocument.Value;
|
||||
MccMcpAgentCapabilityStatus capabilityStatus = BuildCapabilityStatus();
|
||||
StringBuilder builder = new();
|
||||
builder.AppendLine("You are an external agent controlling Minecraft Console Client (MCC) through its built-in MCP server.");
|
||||
builder.AppendLine("Use the following MCP Operator Prompt as your system prompt. Treat the capability snapshot as authoritative and do not invent unsupported actions.");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine(document.BodyMarkdown);
|
||||
builder.AppendLine();
|
||||
builder.AppendLine("Current capability snapshot");
|
||||
builder.AppendLine($"- sessionStatus: {FormatCapability(capabilityStatus.SessionStatus)}");
|
||||
builder.AppendLine($"- chatAndCommands: {FormatCapability(capabilityStatus.ChatAndCommands)}");
|
||||
builder.AppendLine($"- movement: {FormatCapability(capabilityStatus.Movement)}");
|
||||
builder.AppendLine($"- inventory: {FormatCapability(capabilityStatus.Inventory)}");
|
||||
builder.AppendLine($"- entityWorld: {FormatCapability(capabilityStatus.EntityWorld)}");
|
||||
return builder.ToString().Trim();
|
||||
}
|
||||
|
||||
public MccMcpAgentGuidancePayload GetToolPayload()
|
||||
{
|
||||
GuidanceDocument document = guidanceDocument.Value;
|
||||
return new MccMcpAgentGuidancePayload
|
||||
{
|
||||
PromptName = PromptName,
|
||||
PromptMarkdown = document.PromptMarkdown,
|
||||
SkillName = SkillName,
|
||||
GuidanceVersion = document.GuidanceVersion,
|
||||
SkillMarkdown = document.SkillMarkdown,
|
||||
SystemPrompt = GetSystemPrompt(),
|
||||
BestPractices = document.BestPractices,
|
||||
ExampleScenarios = document.ExampleScenarios,
|
||||
CapabilityStatus = BuildCapabilityStatus()
|
||||
};
|
||||
}
|
||||
|
||||
private GuidanceDocument LoadGuidanceDocument()
|
||||
{
|
||||
Assembly assembly = typeof(MccMcpGuidanceProvider).Assembly;
|
||||
string resourceName = assembly.GetManifestResourceNames()
|
||||
.FirstOrDefault(name => name.EndsWith(EmbeddedPromptResourceSuffix, StringComparison.Ordinal))
|
||||
?? throw new InvalidOperationException($"Embedded MCP operator prompt resource '{EmbeddedPromptResourceSuffix}' was not found.");
|
||||
|
||||
using Stream? stream = assembly.GetManifestResourceStream(resourceName);
|
||||
if (stream is null)
|
||||
throw new InvalidOperationException($"Embedded MCP operator prompt resource '{resourceName}' could not be opened.");
|
||||
|
||||
using StreamReader reader = new(stream, Encoding.UTF8);
|
||||
string promptMarkdown = reader.ReadToEnd();
|
||||
string bodyMarkdown = StripFrontmatter(promptMarkdown);
|
||||
string bestPracticesSection = ExtractSection(bodyMarkdown, BestPracticesHeading);
|
||||
string exampleScenariosSection = ExtractSection(bodyMarkdown, ExampleScenariosHeading);
|
||||
|
||||
return new GuidanceDocument(
|
||||
ComputeGuidanceVersion(promptMarkdown),
|
||||
promptMarkdown.Replace("\r\n", "\n").Trim(),
|
||||
bodyMarkdown,
|
||||
ExtractBulletList(bestPracticesSection),
|
||||
ExtractExampleScenarios(exampleScenariosSection));
|
||||
}
|
||||
|
||||
private MccMcpAgentCapabilityStatus BuildCapabilityStatus()
|
||||
{
|
||||
return new MccMcpAgentCapabilityStatus
|
||||
{
|
||||
SessionStatus = config.Capabilities.SessionStatus,
|
||||
ChatAndCommands = config.Capabilities.ChatAndCommands,
|
||||
Movement = config.Capabilities.Movement,
|
||||
Inventory = config.Capabilities.Inventory,
|
||||
EntityWorld = config.Capabilities.EntityWorld
|
||||
};
|
||||
}
|
||||
|
||||
private static string StripFrontmatter(string markdown)
|
||||
{
|
||||
string normalized = markdown.Replace("\r\n", "\n");
|
||||
if (!normalized.StartsWith("---\n", StringComparison.Ordinal))
|
||||
return normalized.Trim();
|
||||
|
||||
int endOfFrontmatter = normalized.IndexOf("\n---\n", 4, StringComparison.Ordinal);
|
||||
if (endOfFrontmatter < 0)
|
||||
return normalized.Trim();
|
||||
|
||||
return normalized[(endOfFrontmatter + 5)..].Trim();
|
||||
}
|
||||
|
||||
private static string ExtractSection(string markdownBody, string heading)
|
||||
{
|
||||
int headingIndex = markdownBody.IndexOf(heading, StringComparison.Ordinal);
|
||||
if (headingIndex < 0)
|
||||
return string.Empty;
|
||||
|
||||
int sectionStart = headingIndex + heading.Length;
|
||||
int nextHeading = markdownBody.IndexOf("\n## ", sectionStart, StringComparison.Ordinal);
|
||||
string section = nextHeading >= 0
|
||||
? markdownBody[sectionStart..nextHeading]
|
||||
: markdownBody[sectionStart..];
|
||||
|
||||
return section.Trim();
|
||||
}
|
||||
|
||||
private static string[] ExtractBulletList(string section)
|
||||
{
|
||||
return section
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(line => line.StartsWith("- ", StringComparison.Ordinal))
|
||||
.Select(line => line[2..].Trim())
|
||||
.Where(line => line.Length > 0)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static MccMcpAgentScenario[] ExtractExampleScenarios(string section)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(section))
|
||||
return [];
|
||||
|
||||
List<MccMcpAgentScenario> scenarios = [];
|
||||
string? currentTitle = null;
|
||||
List<string> currentBodyLines = [];
|
||||
|
||||
foreach (string rawLine in section.Split('\n'))
|
||||
{
|
||||
string line = rawLine.TrimEnd();
|
||||
if (line.StartsWith("### ", StringComparison.Ordinal))
|
||||
{
|
||||
AddScenario(scenarios, currentTitle, currentBodyLines);
|
||||
currentTitle = line[4..].Trim();
|
||||
currentBodyLines = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentTitle is not null)
|
||||
currentBodyLines.Add(line);
|
||||
}
|
||||
|
||||
AddScenario(scenarios, currentTitle, currentBodyLines);
|
||||
return scenarios.ToArray();
|
||||
}
|
||||
|
||||
private static void AddScenario(List<MccMcpAgentScenario> scenarios, string? title, List<string> bodyLines)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
return;
|
||||
|
||||
string guidance = string.Join('\n', bodyLines)
|
||||
.Trim();
|
||||
|
||||
scenarios.Add(new MccMcpAgentScenario
|
||||
{
|
||||
Title = title,
|
||||
Guidance = guidance
|
||||
});
|
||||
}
|
||||
|
||||
private static string FormatCapability(bool enabled)
|
||||
{
|
||||
return enabled ? "enabled" : "disabled";
|
||||
}
|
||||
|
||||
private static string ComputeGuidanceVersion(string markdown)
|
||||
{
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(markdown.Replace("\r\n", "\n").Trim());
|
||||
byte[] hash = SHA256.HashData(bytes);
|
||||
return Convert.ToHexString(hash[..8]).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private sealed record GuidanceDocument(
|
||||
string GuidanceVersion,
|
||||
string SkillMarkdown,
|
||||
string BodyMarkdown,
|
||||
string[] BestPractices,
|
||||
MccMcpAgentScenario[] ExampleScenarios)
|
||||
{
|
||||
public string PromptMarkdown => SkillMarkdown;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class MccMcpAgentGuidancePayload
|
||||
{
|
||||
[JsonPropertyName("promptName")]
|
||||
public string PromptName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("promptMarkdown")]
|
||||
public string PromptMarkdown { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("skillName")]
|
||||
public string SkillName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("guidanceVersion")]
|
||||
public string GuidanceVersion { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("skillMarkdown")]
|
||||
public string SkillMarkdown { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("systemPrompt")]
|
||||
public string SystemPrompt { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("bestPractices")]
|
||||
public string[] BestPractices { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("exampleScenarios")]
|
||||
public MccMcpAgentScenario[] ExampleScenarios { get; init; } = [];
|
||||
|
||||
[JsonPropertyName("capabilityStatus")]
|
||||
public MccMcpAgentCapabilityStatus CapabilityStatus { get; init; } = new();
|
||||
}
|
||||
|
||||
public sealed class MccMcpAgentScenario
|
||||
{
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("guidance")]
|
||||
public string Guidance { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class MccMcpAgentCapabilityStatus
|
||||
{
|
||||
[JsonPropertyName("sessionStatus")]
|
||||
public bool SessionStatus { get; init; }
|
||||
|
||||
[JsonPropertyName("chatAndCommands")]
|
||||
public bool ChatAndCommands { get; init; }
|
||||
|
||||
[JsonPropertyName("movement")]
|
||||
public bool Movement { get; init; }
|
||||
|
||||
[JsonPropertyName("inventory")]
|
||||
public bool Inventory { get; init; }
|
||||
|
||||
[JsonPropertyName("entityWorld")]
|
||||
public bool EntityWorld { get; init; }
|
||||
}
|
||||
20
MinecraftClient/Mcp/MccMcpPromptSet.cs
Normal file
20
MinecraftClient/Mcp/MccMcpPromptSet.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
using System.ComponentModel;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace MinecraftClient.Mcp;
|
||||
|
||||
public sealed class MccMcpPromptSet
|
||||
{
|
||||
private readonly MccMcpGuidanceProvider guidanceProvider;
|
||||
|
||||
public MccMcpPromptSet(MccMcpGuidanceProvider guidanceProvider)
|
||||
{
|
||||
this.guidanceProvider = guidanceProvider;
|
||||
}
|
||||
|
||||
[McpServerPrompt(Name = "mcc_operator_prompt"), Description("Get the canonical MCC MCP Operator Prompt for external agents using this MCP server.")]
|
||||
public string OperatorPrompt()
|
||||
{
|
||||
return guidanceProvider.GetSystemPrompt();
|
||||
}
|
||||
}
|
||||
44
MinecraftClient/Mcp/MccMcpRecentEventStore.cs
Normal file
44
MinecraftClient/Mcp/MccMcpRecentEventStore.cs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using MinecraftClient.Scripting;
|
||||
|
||||
namespace MinecraftClient.Mcp;
|
||||
|
||||
public sealed class MccMcpRecentEventEntry
|
||||
{
|
||||
public required long Id { get; init; }
|
||||
public required DateTimeOffset TimestampUtc { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public object? Data { get; init; }
|
||||
}
|
||||
|
||||
public static class MccMcpRecentEventStore
|
||||
{
|
||||
public static long Add(string type, object? data = null)
|
||||
{
|
||||
return MccObservedStateStore.AddRecentEvent(type, data);
|
||||
}
|
||||
|
||||
public static long GetLatestId()
|
||||
{
|
||||
return MccObservedStateStore.GetLatestRecentEventId();
|
||||
}
|
||||
|
||||
public static MccMcpRecentEventEntry[] GetAfter(long afterId, int maxCount, string? typeFilter = null)
|
||||
{
|
||||
return MccObservedStateStore.GetRecentEventsAfter(afterId, maxCount, typeFilter)
|
||||
.Select(entry => new MccMcpRecentEventEntry
|
||||
{
|
||||
Id = entry.Id,
|
||||
TimestampUtc = entry.TimestampUtc,
|
||||
Type = entry.Type,
|
||||
Data = entry.Data
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
MccObservedStateStore.ClearRecentEvents();
|
||||
}
|
||||
}
|
||||
33
MinecraftClient/Mcp/MccMcpResult.cs
Normal file
33
MinecraftClient/Mcp/MccMcpResult.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
using System;
|
||||
|
||||
namespace MinecraftClient.Mcp;
|
||||
|
||||
public sealed class MccMcpResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? ErrorCode { get; init; }
|
||||
public string? Message { get; init; }
|
||||
public object? Data { get; init; }
|
||||
|
||||
public static MccMcpResult Ok(object? data = null, string? message = null)
|
||||
{
|
||||
return new MccMcpResult
|
||||
{
|
||||
Success = true,
|
||||
Data = data,
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
|
||||
public static MccMcpResult Fail(string errorCode, string? message = null, object? data = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(errorCode);
|
||||
return new MccMcpResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorCode = errorCode,
|
||||
Message = message,
|
||||
Data = data
|
||||
};
|
||||
}
|
||||
}
|
||||
47
MinecraftClient/Mcp/MccMcpRuntimeStateStore.cs
Normal file
47
MinecraftClient/Mcp/MccMcpRuntimeStateStore.cs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
using System;
|
||||
using MinecraftClient.Scripting;
|
||||
|
||||
namespace MinecraftClient.Mcp;
|
||||
|
||||
public sealed class MccMcpRuntimeStateSnapshot
|
||||
{
|
||||
public long? WorldAge { get; init; }
|
||||
public long? TimeOfDay { get; init; }
|
||||
public float? RainLevel { get; init; }
|
||||
public float? ThunderLevel { get; init; }
|
||||
}
|
||||
|
||||
public static class MccMcpRuntimeStateStore
|
||||
{
|
||||
public static void SetTime(long newWorldAge, long newTimeOfDay)
|
||||
{
|
||||
MccObservedStateStore.SetTime(newWorldAge, newTimeOfDay);
|
||||
}
|
||||
|
||||
public static void SetRainLevel(float level)
|
||||
{
|
||||
MccObservedStateStore.SetRainLevel(level);
|
||||
}
|
||||
|
||||
public static void SetThunderLevel(float level)
|
||||
{
|
||||
MccObservedStateStore.SetThunderLevel(level);
|
||||
}
|
||||
|
||||
public static MccMcpRuntimeStateSnapshot GetSnapshot()
|
||||
{
|
||||
MccRuntimeStateSnapshot snapshot = MccObservedStateStore.GetRuntimeStateSnapshot();
|
||||
return new MccMcpRuntimeStateSnapshot
|
||||
{
|
||||
WorldAge = snapshot.WorldAge,
|
||||
TimeOfDay = snapshot.TimeOfDay,
|
||||
RainLevel = snapshot.RainLevel,
|
||||
ThunderLevel = snapshot.ThunderLevel
|
||||
};
|
||||
}
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
MccObservedStateStore.ClearRuntimeState();
|
||||
}
|
||||
}
|
||||
401
MinecraftClient/Mcp/MccMcpToolSet.cs
Normal file
401
MinecraftClient/Mcp/MccMcpToolSet.cs
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
using System.ComponentModel;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace MinecraftClient.Mcp;
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class MccMcpToolSet
|
||||
{
|
||||
private readonly IMccMcpCapabilities capabilities;
|
||||
private readonly MccMcpGuidanceProvider guidanceProvider;
|
||||
|
||||
public MccMcpToolSet(IMccMcpCapabilities capabilities, MccMcpGuidanceProvider guidanceProvider)
|
||||
{
|
||||
this.capabilities = capabilities;
|
||||
this.guidanceProvider = guidanceProvider;
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_session_status"), Description("Get current MCC session and feature status.")]
|
||||
public object SessionStatus()
|
||||
{
|
||||
return capabilities.GetSessionStatus();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_server_info"), Description("Get active MCC server connection info and current TPS.")]
|
||||
public object ServerInfo()
|
||||
{
|
||||
return capabilities.GetServerInfo();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_player_state"), Description("Get current controlled player state.")]
|
||||
public object PlayerState()
|
||||
{
|
||||
return capabilities.GetPlayerState();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_world_state"), Description("Get current world state, chunk loading progress, and last observed runtime time/weather values.")]
|
||||
public object WorldState()
|
||||
{
|
||||
return capabilities.GetWorldState();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_chunk_status"), Description("Get chunk loading status for the player location or an explicit world coordinate.")]
|
||||
public object ChunkStatus(double? x = null, double? y = null, double? z = null)
|
||||
{
|
||||
return capabilities.GetChunkStatus(x, y, z);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_raycast_block"), Description("Raycast from the player's current view and return the first non-air block hit.")]
|
||||
public object RaycastBlock(double maxDistance = 8.0, bool includeNeighbors = false)
|
||||
{
|
||||
return capabilities.RaycastBlock(maxDistance, includeNeighbors);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_path_preview"), Description("Compute a path preview to a target world coordinate without moving there.")]
|
||||
public object PathPreview(double x, double y, double z, bool allowUnsafe = false, int maxOffset = 0, int minOffset = 0, int timeoutMs = 0, int maxWaypoints = 128)
|
||||
{
|
||||
return capabilities.PreviewPath(x, y, z, allowUnsafe, maxOffset, minOffset, timeoutMs, maxWaypoints);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_players_list"), Description("List currently known online players.")]
|
||||
public object PlayersList()
|
||||
{
|
||||
return capabilities.GetPlayersList();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_players_detailed"), Description("List online players with UUID, latency, gamemode, and tracked coordinates when available.")]
|
||||
public object PlayersDetailed(bool includeSelf = false, bool includeCoordinates = true)
|
||||
{
|
||||
return capabilities.GetPlayersDetailed(includeSelf, includeCoordinates);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_player_stats"), Description("Get current controlled player stats, orientation, and location.")]
|
||||
public object PlayerStats()
|
||||
{
|
||||
return capabilities.GetPlayerStats();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_status_effects"), Description("Get active player status effects only.")]
|
||||
public object StatusEffects()
|
||||
{
|
||||
return capabilities.GetStatusEffects();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_recent_events"), Description("Get recent high-signal MCP runtime events after a given event ID.")]
|
||||
public object RecentEvents(long afterId = 0, int maxCount = 50, string? typeFilter = null)
|
||||
{
|
||||
return capabilities.GetRecentEvents(afterId, maxCount, typeFilter);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_loaded_bots"), Description("List currently loaded MCC bots and scripts.")]
|
||||
public object LoadedBots()
|
||||
{
|
||||
return capabilities.GetLoadedBots();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_chat_history"), Description("Get recent chat/system lines seen by MCC.")]
|
||||
public object ChatHistory(int maxCount = 50, bool includeJson = false)
|
||||
{
|
||||
return capabilities.GetChatHistory(maxCount, includeJson);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_internal_commands_list"), Description("List available MCC internal commands with usage and description.")]
|
||||
public object InternalCommandsList()
|
||||
{
|
||||
return capabilities.GetInternalCommands();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_agent_guidance"), Description("Get the canonical MCC MCP Operator Prompt bundle for external agents using this MCP server.")]
|
||||
public object AgentGuidance()
|
||||
{
|
||||
return guidanceProvider.GetToolPayload();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_materials_list"), Description("List known MCC material names with optional filtering.")]
|
||||
public object MaterialsList(string? filter = null, int maxCount = 500)
|
||||
{
|
||||
return capabilities.GetMaterialsList(filter, maxCount);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_block_types_list"), Description("List known MCC block type names with optional filtering.")]
|
||||
public object BlockTypesList(string? filter = null, int maxCount = 500)
|
||||
{
|
||||
return capabilities.GetBlockTypesList(filter, maxCount);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_entity_types_list"), Description("List known MCC entity type names with optional filtering.")]
|
||||
public object EntityTypesList(string? filter = null, int maxCount = 500)
|
||||
{
|
||||
return capabilities.GetEntityTypesList(filter, maxCount);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_send_chat"), Description("Send chat text or slash-command to the connected Minecraft server.")]
|
||||
public object SendChat([Description("Text to send to server chat.")] string text)
|
||||
{
|
||||
return capabilities.SendChat(text);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_quit_client"), Description("Quit MCC client process cleanly.")]
|
||||
public object QuitClient()
|
||||
{
|
||||
return capabilities.QuitClient();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_disconnect"), Description("Disconnect MCC from the current server without quitting the process.")]
|
||||
public object Disconnect()
|
||||
{
|
||||
return capabilities.DisconnectClient();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_respawn"), Description("Send the respawn packet when the controlled player is dead.")]
|
||||
public object Respawn()
|
||||
{
|
||||
return capabilities.Respawn();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_run_internal_command"), Description("Run an internal MCC command.")]
|
||||
public object RunInternalCommand([Description("MCC command line without leading slash.")] string command)
|
||||
{
|
||||
return capabilities.RunInternalCommand(command);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_animation"), Description("Play a hand-swing animation with the selected hand.")]
|
||||
public object Animation(string hand = "MainHand")
|
||||
{
|
||||
return capabilities.PlayAnimation(hand);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_toggle_sneak"), Description("Explicitly enable or disable sneaking.")]
|
||||
public object ToggleSneak(bool enabled)
|
||||
{
|
||||
return capabilities.ToggleSneak(enabled);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_toggle_sprint"), Description("Explicitly send start or stop sprinting entity actions.")]
|
||||
public object ToggleSprint(bool enabled)
|
||||
{
|
||||
return capabilities.ToggleSprint(enabled);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_change_hotbar_slot"), Description("Change active hotbar slot (1-9).")]
|
||||
public object ChangeHotbarSlot(int slot)
|
||||
{
|
||||
return capabilities.ChangeHotbarSlot(slot);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_select_item"), Description("Select a hotbar item by item type without rearranging inventory contents.")]
|
||||
public object SelectItem(string itemType, bool preferLowestSlot = true)
|
||||
{
|
||||
return capabilities.SelectHotbarItem(itemType, preferLowestSlot);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_use_item_on_hand"), Description("Use the currently held item.")]
|
||||
public object UseItemOnHand()
|
||||
{
|
||||
return capabilities.UseItemOnHand();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_use_item_on_block"), Description("Use currently held item on a target block location.")]
|
||||
public object UseItemOnBlock(double x, double y, double z)
|
||||
{
|
||||
return capabilities.UseItemOnBlock(x, y, z);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_dig_block"), Description("Dig a block at target location.")]
|
||||
public object DigBlock(double x, double y, double z, double durationSeconds = 0)
|
||||
{
|
||||
return capabilities.DigBlock(x, y, z, durationSeconds);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_place_block"), Description("Place the currently held block/item at a target block location.")]
|
||||
public object PlaceBlock(int x, int y, int z, string face = "Up", string hand = "MainHand", bool lookAtBlock = false)
|
||||
{
|
||||
return capabilities.PlaceBlock(x, y, z, face, hand, lookAtBlock);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_entity_interact"), Description("Interact with a tracked entity.")]
|
||||
public object EntityInteract(int entityId, string interaction = "Interact", string hand = "MainHand")
|
||||
{
|
||||
return capabilities.InteractEntity(entityId, interaction, hand);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_entity_attack"), Description("Attack a tracked entity explicitly.")]
|
||||
public object EntityAttack(int entityId)
|
||||
{
|
||||
return capabilities.AttackEntity(entityId);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_block_scan"), Description("Scan nearby blocks around player location.")]
|
||||
public object BlockScan(int radius = 3, int maxCount = 200, string? materialFilter = null)
|
||||
{
|
||||
return capabilities.ScanNearbyBlocks(radius, maxCount, materialFilter);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_blocks_find"), Description("Find nearby blocks by block name/type query or block ID.")]
|
||||
public object BlocksFind(string? query = null, int radius = 6, int maxCount = 200, bool exactMatch = false)
|
||||
{
|
||||
return capabilities.FindBlocks(query, radius, maxCount, exactMatch);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_player_nearby"), Description("Check if any player, or a specific player, is nearby.")]
|
||||
public object PlayerNearby(string? playerName = null, double radius = 32, bool includeSelf = false)
|
||||
{
|
||||
return capabilities.IsPlayerNearby(playerName, radius, includeSelf);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_player_locate"), Description("Locate a tracked player entity by name and return exact coordinates when available.")]
|
||||
public object PlayerLocate(string playerName, bool includeSelf = false)
|
||||
{
|
||||
return capabilities.LocatePlayer(playerName, includeSelf);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_entity_nearest"), Description("Return the nearest tracked entity matching the requested filters.")]
|
||||
public object EntityNearest(string? typeFilter = null, string? nameFilter = null, double radius = 64.0, bool includePlayers = true)
|
||||
{
|
||||
return capabilities.FindNearestEntity(typeFilter, nameFilter, radius, includePlayers);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_can_reach_position"), Description("Check whether MCC can currently path to a world coordinate without moving there.")]
|
||||
public object CanReachPosition(double x, double y, double z, bool allowUnsafe = false, int maxOffset = 0, int minOffset = 0, int timeoutMs = 0)
|
||||
{
|
||||
return capabilities.CanReachPosition(x, y, z, allowUnsafe, maxOffset, minOffset, timeoutMs);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_move_to"), Description("Request movement/pathing to a world coordinate and verify arrival.")]
|
||||
public object MoveTo(double x, double y, double z, bool allowUnsafe = false, bool allowDirectTeleport = false, int maxOffset = 0, int minOffset = 0, int timeoutMs = 0)
|
||||
{
|
||||
return capabilities.MoveTo(x, y, z, allowUnsafe, allowDirectTeleport, maxOffset, minOffset, timeoutMs);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_move_to_player"), Description("Locate a tracked player entity, request movement/pathing, and verify arrival.")]
|
||||
public object MoveToPlayer(string playerName, bool allowUnsafe = false, bool allowDirectTeleport = false, int maxOffset = 0, int minOffset = 0, int timeoutMs = 0)
|
||||
{
|
||||
return capabilities.MoveToPlayer(playerName, allowUnsafe, allowDirectTeleport, maxOffset, minOffset, timeoutMs);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_look_at"), Description("Rotate player view toward world coordinates.")]
|
||||
public object LookAt(double x, double y, double z)
|
||||
{
|
||||
return capabilities.LookAt(x, y, z);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_look_direction"), Description("Rotate player view to a cardinal direction or straight up/down.")]
|
||||
public object LookDirection(string direction)
|
||||
{
|
||||
return capabilities.LookDirection(direction);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_look_angles"), Description("Rotate player view to explicit yaw and pitch angles.")]
|
||||
public object LookAngles(float yaw, float pitch)
|
||||
{
|
||||
return capabilities.LookAngles(yaw, pitch);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_inventory_snapshot"), Description("Get a snapshot of one inventory.")]
|
||||
public object InventorySnapshot([Description("Inventory ID. 0 is the player inventory.")] int inventoryId = 0)
|
||||
{
|
||||
return capabilities.GetInventorySnapshot(inventoryId);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_inventory_search"), Description("Search the player inventory and optionally open containers for items matching a query.")]
|
||||
public object InventorySearch(string query, int maxCount = 100, bool exactMatch = false, bool includeContainers = true)
|
||||
{
|
||||
return capabilities.SearchInventories(query, maxCount, exactMatch, includeContainers);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_inventories_list"), Description("List currently open inventories and containers known to MCC.")]
|
||||
public object InventoriesList()
|
||||
{
|
||||
return capabilities.ListInventories();
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_container_open_at"), Description("Open an interactable container block at world coordinates and wait for the container inventory to appear.")]
|
||||
public object ContainerOpenAt(int x, int y, int z, int timeoutMs = 0, bool closeCurrent = true)
|
||||
{
|
||||
return capabilities.OpenContainerAt(x, y, z, timeoutMs, closeCurrent);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_container_close"), Description("Close an open non-player container. Use inventoryId=-1 to close the active container.")]
|
||||
public object ContainerClose([Description("Container inventory ID, or -1 for the active non-player container.")] int inventoryId = -1, int timeoutMs = 0)
|
||||
{
|
||||
return capabilities.CloseContainer(inventoryId, timeoutMs);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_inventory_window_action"), Description("Perform a window action on an inventory slot.")]
|
||||
public object InventoryWindowAction(int inventoryId, int slotId, [Description("WindowActionType enum name, e.g. LeftClick or ShiftClick.")] string actionType)
|
||||
{
|
||||
return capabilities.InventoryWindowAction(inventoryId, slotId, actionType);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_inventory_drop_item"), Description("Drop an exact item count from an inventory by item type.")]
|
||||
public object InventoryDropItem(
|
||||
[Description("Item type enum name (e.g. Diamond).")] string itemType,
|
||||
[Description("Exact number of items to drop.")] int count,
|
||||
[Description("Inventory ID. 0 is the player inventory.")] int inventoryId = 0,
|
||||
[Description("Prefer dropping from larger stacks first when true.")] bool preferStack = false)
|
||||
{
|
||||
return capabilities.DropInventoryItem(itemType, count, inventoryId, preferStack);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_container_deposit_item"), Description("Move an exact item count from the player inventory into an open container and verify the transfer.")]
|
||||
public object ContainerDepositItem(
|
||||
[Description("Item type enum name (e.g. Diamond).")] string itemType,
|
||||
[Description("Exact number of items to move into the container.")] int count,
|
||||
[Description("Container inventory ID, or -1 for the active non-player container.")] int inventoryId = -1,
|
||||
[Description("Prefer larger source stacks first when true.")] bool preferLargestStack = true)
|
||||
{
|
||||
return capabilities.DepositContainerItem(itemType, count, inventoryId, preferLargestStack);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_container_withdraw_item"), Description("Move an exact item count from an open container into the player inventory and verify the transfer.")]
|
||||
public object ContainerWithdrawItem(
|
||||
[Description("Item type enum name (e.g. Diamond).")] string itemType,
|
||||
[Description("Exact number of items to move into the player inventory.")] int count,
|
||||
[Description("Container inventory ID, or -1 for the active non-player container.")] int inventoryId = -1,
|
||||
[Description("Prefer larger source stacks first when true.")] bool preferLargestStack = true)
|
||||
{
|
||||
return capabilities.WithdrawContainerItem(itemType, count, inventoryId, preferLargestStack);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_entities_query"), Description("Query tracked entities.")]
|
||||
public object EntitiesQuery([Description("Maximum entities to return.")] int maxCount = 50)
|
||||
{
|
||||
return capabilities.QueryEntities(maxCount);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_entities_list"), Description("List tracked entities with optional type and radius filtering.")]
|
||||
public object EntitiesList(int maxCount = 100, string? typeFilter = null, double radius = 0)
|
||||
{
|
||||
return capabilities.ListEntities(maxCount, typeFilter, radius);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_entity_info"), Description("Get detailed info for one tracked entity.")]
|
||||
public object EntityInfo(int entityId, bool includeMetadata = false, bool includeEquipment = true, bool includeEffects = true)
|
||||
{
|
||||
return capabilities.GetEntityInfo(entityId, includeMetadata, includeEquipment, includeEffects);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_signs_find"), Description("Find nearby signs whose text exactly matches or contains the requested text.")]
|
||||
public object SignsFind(string text, bool exactMatch = false, int radius = 16, int maxCount = 50, bool includeBackText = true)
|
||||
{
|
||||
return capabilities.FindSigns(text, exactMatch, radius, maxCount, includeBackText);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_items_list"), Description("List nearby dropped item entities with optional item type filtering.")]
|
||||
public object ItemsList(string? itemType = null, double radius = 32, int maxCount = 100)
|
||||
{
|
||||
return capabilities.ListItemEntities(itemType, radius, maxCount);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_items_pickup"), Description("Move to and pick up nearby dropped items of a given item type.")]
|
||||
public object ItemsPickup(string itemType, double radius = 32, int maxItems = 20, bool allowUnsafe = false, int timeoutMs = 0)
|
||||
{
|
||||
return capabilities.PickupItems(itemType, radius, maxItems, allowUnsafe, timeoutMs);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "mcc_world_block_at"), Description("Get block information at world coordinates.")]
|
||||
public object WorldBlockAt(int x, int y, int z)
|
||||
{
|
||||
return capabilities.GetWorldBlockAt(x, y, z);
|
||||
}
|
||||
}
|
||||
139
MinecraftClient/Mcp/Prompts/MccMcpOperatorPrompt.md
Normal file
139
MinecraftClient/Mcp/Prompts/MccMcpOperatorPrompt.md
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
# MCC MCP Operator Prompt
|
||||
|
||||
Use the MCC MCP toolset as the source of truth for game state and action results.
|
||||
Do not guess what happened from intent alone.
|
||||
If tool results and fresh observations disagree, trust the freshest direct observation and report the conflict honestly.
|
||||
|
||||
## Operating Loop
|
||||
|
||||
1. Inspect the current situation before acting.
|
||||
2. Make the shortest plan that can succeed.
|
||||
3. Use the smallest set of high-signal tools needed to act.
|
||||
4. Verify the outcome with fresh tool calls that read the state again after the action.
|
||||
5. Report only what is verified, and clearly label anything inferred, conflicting, or still unknown.
|
||||
|
||||
If the request is purely conversational and does not require MCC state, answer directly instead of wasting tool calls.
|
||||
If the user asks for a before/after comparison, keep or obtain explicit before and after observations. If you do not have a verified baseline, say so instead of reconstructing history from memory.
|
||||
|
||||
## Tool Selection Rules
|
||||
|
||||
- Start with `mcc_session_status` whenever connection state, enabled capabilities, or feature availability is uncertain.
|
||||
- Prefer direct inspection tools such as `mcc_world_state`, `mcc_chunk_status`, `mcc_player_state`, `mcc_player_stats`, `mcc_players_detailed`, `mcc_entities_list`, `mcc_entity_nearest`, `mcc_blocks_find`, `mcc_raycast_block`, `mcc_items_list`, `mcc_inventory_snapshot`, and `mcc_inventory_search` before taking physical actions.
|
||||
- For player-relative tasks, prefer `mcc_player_locate` or `mcc_players_detailed` before movement so the target is grounded in a fresh tracked position.
|
||||
- Prefer purpose-built action tools over low-level escape hatches.
|
||||
- Prefer `mcc_container_open_at`, `mcc_container_deposit_item`, and `mcc_container_withdraw_item` over `mcc_inventory_window_action` for chest or container work.
|
||||
- Use `mcc_path_preview`, `mcc_can_reach_position`, or a locating tool before pathing when reachability or final approach quality is uncertain.
|
||||
- Use `mcc_select_item` instead of manual slot changes when the goal is "hold the right item now".
|
||||
- Use `mcc_look_direction`, `mcc_look_angles`, or `mcc_look_at` before `mcc_raycast_block`, `mcc_use_item_on_block`, or precise block interaction when view direction matters.
|
||||
- Use `mcc_recent_events` when verifying outcomes that should produce a clear runtime event, such as `inventory_open`, `inventory_close`, `death`, `respawn`, `title`, or `actionbar`.
|
||||
- Use `mcc_status_effects` when active effects matter, instead of inferring them from health or movement behavior.
|
||||
- Use `mcc_loaded_bots` when bot/script presence could affect observed behavior.
|
||||
- For "give this item to that player" requests, remember there is usually no direct inventory-to-inventory transfer tool. The normal MCP-compatible handoff is to move near the player and drop the item near them unless a more specific interaction tool clearly applies.
|
||||
- Use `mcc_run_internal_command` only when no purpose-built MCP tool covers the task cleanly.
|
||||
- Treat `success=false`, `action_incomplete`, `capability_disabled`, `feature_disabled`, and `invalid_args` as failed or partial observations, not success.
|
||||
- After `invalid_args`, simplify the call and try at most one nearby variant. Do not spam near-duplicate guesses.
|
||||
- Do not repeat the same failing action over and over. If an action fails or arrives short, gather one new observation that changes the plan before retrying.
|
||||
|
||||
## Verification Rules
|
||||
|
||||
- World-state assumptions should be verified with `mcc_world_state` or `mcc_chunk_status` when chunk loading, dimension, or time/weather readiness affects the plan.
|
||||
- When the user cares about deltas, keep explicit before and after evidence for the exact thing that changed.
|
||||
- If an action tool reports success but a fresh observation disagrees, trust the fresh observation and report the action as failed, partial, or inconclusive.
|
||||
- Movement is not complete just because a move request was accepted. Confirm `arrived=true` or verify the new location with a fresh state read.
|
||||
- A later successful retry proves the final state, not that every earlier attempt also succeeded.
|
||||
- A path preview is not proof of arrival. Treat `mcc_path_preview` as planning evidence only, then verify the actual move separately.
|
||||
- Digging is not complete just because `mcc_dig_block` was invoked. Re-check the target block or nearby block search results.
|
||||
- View-dependent block interaction should be verified with `mcc_raycast_block` or `mcc_world_block_at` before and after the action when precision matters.
|
||||
- Item pickup is not complete just because the bot moved over an item. Re-check inventory state or nearby dropped-item entities.
|
||||
- Hotbar selection is not complete just because `mcc_select_item` returned success. Confirm the selected slot or held state with `mcc_player_stats` or a fresh inventory read.
|
||||
- Dropping an item is not fully verified from intent alone. Re-check the inventory with a fresh `mcc_inventory_snapshot`. When useful, also inspect nearby dropped-item entities with `mcc_items_list`.
|
||||
- Removing an item from your inventory does not prove another player received it. It only proves the item left your inventory or moved elsewhere. Claim that you "gave" or "delivered" an item to a player only if the stronger claim is supported. Otherwise say that you dropped it near them.
|
||||
- Container transfers are not complete just because a click or transfer request was accepted. Verify the resulting counts after the transfer.
|
||||
- Entity targeting should be verified with `mcc_entity_nearest`, `mcc_entity_info`, or another fresh entity read if the target could have moved or despawned.
|
||||
- Use `mcc_recent_events` to verify eventful outcomes such as inventory open/close, death, respawn, title/actionbar messages, or similar runtime signals.
|
||||
- Chat or command effects should be verified through state changes, chat history, or another direct observation when possible.
|
||||
- When evidence is partial, say exactly what was verified and what remains unverified.
|
||||
- When observations conflict, report the conflict plainly instead of smoothing it over.
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Query first, act second, verify third.
|
||||
- Keep plans short and concrete. Long speculative tool chains usually make the result worse.
|
||||
- Prefer high-signal tools that answer the real question directly.
|
||||
- Prefer newer structured reads like `mcc_world_state`, `mcc_player_stats`, `mcc_players_detailed`, `mcc_inventory_search`, and `mcc_recent_events` when they answer the question more directly than older generic tools.
|
||||
- Use structured inventory and container tools instead of raw slot manipulation whenever possible.
|
||||
- Do not claim success from acceptance alone. Always pair actions with a follow-up observation.
|
||||
- Use fresh post-action reads for high-value or irreversible actions such as dropping items, moving items between inventories, placing blocks, digging blocks, attacking entities, disconnecting, or quitting.
|
||||
- Preserve baselines when the user is likely to ask "what changed?" or "what did you have before?" later.
|
||||
- Distinguish verified facts, reasonable inferences, and unknowns in the final answer.
|
||||
- Use precise verbs. "Moved near", "dropped", "opened", "deposited", "withdrew", and "picked up" are stronger and safer than vague success language.
|
||||
- Reserve "gave to player", "delivered", or similar wording for cases where that stronger claim is actually supported by evidence.
|
||||
- If a tool says a capability or feature is disabled, stop using tools from that category and explain the limitation.
|
||||
- If a path fails or arrives short, revise the plan using the latest position instead of blindly retrying the same action.
|
||||
- Use `mcc_quit_client` to stop MCC. Do not send bare `quit` or `exit` through chat.
|
||||
- Keep the final response concise and grounded in the evidence you actually collected.
|
||||
|
||||
## Example Scenarios
|
||||
|
||||
### Move to a player and confirm proximity
|
||||
|
||||
User intent: "Find Zarko and move near them."
|
||||
|
||||
Good flow:
|
||||
- call `mcc_player_locate` or `mcc_players_list` to confirm the player is known
|
||||
- if needed, call `mcc_players_detailed` for exact coordinates and `mcc_path_preview` or `mcc_can_reach_position` for the target area
|
||||
- call `mcc_move_to_player`
|
||||
- verify `arrived=true` or confirm the new position with `mcc_player_stats`
|
||||
- report whether proximity was verified or only partially achieved
|
||||
|
||||
### Drop an item for a player and report the result honestly
|
||||
|
||||
User intent: "Move to Zarko and give them one dirt."
|
||||
|
||||
Good flow:
|
||||
- call `mcc_player_locate` to ground the target player
|
||||
- call `mcc_inventory_snapshot` or `mcc_inventory_search` if item availability is uncertain
|
||||
- call `mcc_move_to_player`
|
||||
- call `mcc_inventory_drop_item`
|
||||
- verify the result with a fresh `mcc_inventory_snapshot`
|
||||
- if useful, call `mcc_items_list` nearby to confirm a dropped item entity exists near the handoff location
|
||||
- report "dropped 1 dirt near Zarko" unless you actually observed stronger delivery evidence such as pickup
|
||||
|
||||
### Open a chest, move an exact item count, and verify the result
|
||||
|
||||
User intent: "Put 5 diamonds in the chest at 11000 64 11021."
|
||||
|
||||
Good flow:
|
||||
- call `mcc_container_open_at`
|
||||
- inspect current state with `mcc_inventory_search` or `mcc_inventory_snapshot` if item availability is unclear
|
||||
- call `mcc_container_deposit_item` or `mcc_container_withdraw_item`
|
||||
- verify the resulting counts from the transfer result and, when useful, a fresh inventory snapshot or `mcc_recent_events`
|
||||
- report the exact verified delta, not just that the action was attempted
|
||||
|
||||
### Collect nearby dropped items or dig target blocks and verify the outcome
|
||||
|
||||
User intent: "Pick up nearby apples" or "Break those logs and collect them."
|
||||
|
||||
Good flow:
|
||||
- call `mcc_items_list`, `mcc_blocks_find`, or `mcc_raycast_block` to locate the target
|
||||
- move only if the target is not already reachable from the current position
|
||||
- call `mcc_items_pickup` for dropped items, or `mcc_dig_block` in a sensible order for blocks
|
||||
- verify the result with `mcc_items_list`, `mcc_inventory_snapshot`, or a fresh block query
|
||||
- if the result is partial, say what changed and what still remains
|
||||
|
||||
### Answer a before/after question without inventing history
|
||||
|
||||
User intent: "What changed after that action?" or "What did you have before?"
|
||||
|
||||
Good flow:
|
||||
- if you already captured before and after reads, compare them directly
|
||||
- if you only have current state, say what is currently verified and explicitly note that the earlier baseline was not captured
|
||||
- do not backfill a historical claim just because it would make the story sound consistent
|
||||
|
||||
## Output Style
|
||||
|
||||
- Lead with the outcome the user cares about.
|
||||
- Include the small set of observations that justify the answer.
|
||||
- If something failed, say what failed, what was verified anyway, and the next sensible step.
|
||||
- If you dropped an item near a player but did not observe pickup, say exactly that.
|
||||
- Do not embellish uncertain results.
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Physics\BlockShapeData.json" LogicalName="BlockShapeData.json" />
|
||||
<EmbeddedResource Include="Mcp\Prompts\MccMcpOperatorPrompt.md" LogicalName="MccMcpOperatorPrompt.md" />
|
||||
<EmbeddedResource Include="Tui\MinimapBlockColors.json" LogicalName="MinimapBlockColors.json" />
|
||||
<EmbeddedResource Include="Tui\MinimapEntityCategories.json" LogicalName="MinimapEntityCategories.json" />
|
||||
</ItemGroup>
|
||||
|
|
@ -41,6 +42,8 @@
|
|||
<PackageReference Include="FuzzySharp" Version="2.0.2" />
|
||||
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.11.0" />
|
||||
<PackageReference Include="MessagePack" Version="3.1.4" />
|
||||
<PackageReference Include="ModelContextProtocol" Version="1.1.0" />
|
||||
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.1.0" />
|
||||
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
|
||||
<PackageReference Include="Microsoft.AspNet.WebApi.Client" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Windows.Compatibility" Version="10.0.5" />
|
||||
|
|
|
|||
|
|
@ -890,13 +890,14 @@ namespace MinecraftClient
|
|||
/// <param name="keepAccountAndServerSettings">Optional, keep account and server settings</param>
|
||||
public static void Restart(int delaySeconds = 0, bool keepAccountAndServerSettings = false)
|
||||
{
|
||||
ConsoleIO.Backend.StopReadThread();
|
||||
ConsoleIO.Backend?.StopReadThread();
|
||||
new Thread(new ThreadStart(delegate
|
||||
{
|
||||
if (client is not null) { client.Disconnect(); ConsoleIO.Reset(); }
|
||||
if (offlinePrompt is not null)
|
||||
{
|
||||
ConsoleIO.Backend.OnInputChange -= ConsoleIO.OfflineAutocompleteHandler;
|
||||
if (ConsoleIO.Backend is not null)
|
||||
ConsoleIO.Backend.OnInputChange -= ConsoleIO.OfflineAutocompleteHandler;
|
||||
offlinePrompt.Item2.Cancel(); offlinePrompt.Item1.Join(); offlinePrompt = null; ConsoleIO.Reset();
|
||||
}
|
||||
if (delaySeconds > 0)
|
||||
|
|
@ -918,7 +919,8 @@ namespace MinecraftClient
|
|||
if (client is not null) { client.Disconnect(); ConsoleIO.Reset(); }
|
||||
if (offlinePrompt is not null)
|
||||
{
|
||||
ConsoleIO.Backend.OnInputChange -= ConsoleIO.OfflineAutocompleteHandler;
|
||||
if (ConsoleIO.Backend is not null)
|
||||
ConsoleIO.Backend.OnInputChange -= ConsoleIO.OfflineAutocompleteHandler;
|
||||
offlinePrompt.Item2.Cancel();
|
||||
if (Thread.CurrentThread != offlinePrompt.Item1)
|
||||
offlinePrompt.Item1.Join(1000);
|
||||
|
|
@ -990,8 +992,9 @@ namespace MinecraftClient
|
|||
|
||||
if (offlinePrompt is null)
|
||||
{
|
||||
ConsoleIO.Backend.StopReadThread();
|
||||
ConsoleIO.Backend.OnInputChange += ConsoleIO.OfflineAutocompleteHandler;
|
||||
ConsoleIO.Backend?.StopReadThread();
|
||||
if (ConsoleIO.Backend is not null)
|
||||
ConsoleIO.Backend.OnInputChange += ConsoleIO.OfflineAutocompleteHandler;
|
||||
|
||||
var cancellationTokenSource = new CancellationTokenSource();
|
||||
offlinePrompt = new(new Thread(new ThreadStart(delegate
|
||||
|
|
|
|||
|
|
@ -1577,6 +1577,7 @@ namespace MinecraftClient.Protocol.Handlers
|
|||
var dataSize = dataTypes.ReadNextVarInt(packetData); // Size
|
||||
|
||||
pTerrain.ProcessChunkColumnData(chunkX, chunkZ, verticalStripBitmask, packetData);
|
||||
ProcessChunkBlockEntityData(chunkX, chunkZ, packetData);
|
||||
Interlocked.Decrement(ref handler.GetWorld().chunkLoadNotCompleted);
|
||||
|
||||
// Block Entity data: ignored
|
||||
|
|
@ -3151,17 +3152,16 @@ namespace MinecraftClient.Protocol.Handlers
|
|||
|
||||
// TODO: Use
|
||||
break;
|
||||
case PacketTypesIn.BlockEntityData:
|
||||
if (handler.GetTerrainEnabled() && protocolVersion >= MC_1_17_Version)
|
||||
{
|
||||
var location_ = dataTypes.ReadNextLocation(packetData);
|
||||
dataTypes.ReadNextVarInt(packetData); // Block entity type registry id
|
||||
var nbt = dataTypes.ReadNextNbt(packetData);
|
||||
handler.OnBlockEntityData(location_, nbt);
|
||||
}
|
||||
|
||||
// Temporarily disabled until I find a fix
|
||||
/*case PacketTypesIn.BlockEntityData:
|
||||
var location_ = dataTypes.ReadNextLocation(packetData);
|
||||
var type_ = dataTypes.ReadNextInt(packetData);
|
||||
var nbt = dataTypes.ReadNextNbt(packetData);
|
||||
var nbtJson = JsonConvert.SerializeObject(nbt["messages"]);
|
||||
|
||||
//log.Info($"BLOCK ENTITY DATA -> {location_.ToString()} [{type_}] -> NBT: {nbtJson}");
|
||||
|
||||
break;*/
|
||||
break;
|
||||
|
||||
case PacketTypesIn.SetTickingState:
|
||||
dataTypes.ReadNextFloat(packetData);
|
||||
|
|
@ -3901,6 +3901,24 @@ namespace MinecraftClient.Protocol.Handlers
|
|||
SendPacket(packetPalette.GetOutgoingIdByType(packet), packetData);
|
||||
}
|
||||
|
||||
private void ProcessChunkBlockEntityData(int chunkX, int chunkZ, Queue<byte> packetData)
|
||||
{
|
||||
if (protocolVersion < MC_1_17_Version || packetData.Count == 0)
|
||||
return;
|
||||
|
||||
int blockEntityCount = dataTypes.ReadNextVarInt(packetData);
|
||||
for (int i = 0; i < blockEntityCount; i++)
|
||||
{
|
||||
int packedXZ = dataTypes.ReadNextByte(packetData);
|
||||
int y = dataTypes.ReadNextShort(packetData);
|
||||
dataTypes.ReadNextVarInt(packetData); // Block entity type registry id
|
||||
Dictionary<string, object>? nbt = dataTypes.ReadNextNbt(packetData);
|
||||
int blockX = chunkX * Chunk.SizeX + ((packedXZ >> 4) & 0x0F);
|
||||
int blockZ = chunkZ * Chunk.SizeZ + (packedXZ & 0x0F);
|
||||
handler.OnBlockEntityData(new Location(blockX, y, blockZ), nbt);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send a configuration packet to the server. Packet ID, compression, and encryption will be handled automatically.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -546,6 +546,13 @@ namespace MinecraftClient.Protocol
|
|||
/// <param name="block">The block</param>
|
||||
public void OnBlockChange(Location location, Block block);
|
||||
|
||||
/// <summary>
|
||||
/// Called when block entity update data is received for a loaded block.
|
||||
/// </summary>
|
||||
/// <param name="location">The block location.</param>
|
||||
/// <param name="nbt">The block entity NBT payload.</param>
|
||||
public void OnBlockEntityData(Location location, Dictionary<string, object>? nbt);
|
||||
|
||||
/// <summary>
|
||||
/// Called when "AutoComplete" completes.
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -1002,4 +1002,46 @@ Note: This does NOT require a Bot Token, only an Application ID. Discord must be
|
|||
<data name="Main.General.AuthlibUser" xml:space="preserve">
|
||||
<value>Yggdrasil authlib multi-user selection.</value>
|
||||
</data>
|
||||
<data name="ChatBot.McpServer" xml:space="preserve">
|
||||
<value>Host an embedded MCP server while connected to Minecraft. Disabled by default.</value>
|
||||
</data>
|
||||
<data name="ChatBot.McpServer.Transport" xml:space="preserve">
|
||||
<value>Embedded MCP HTTP transport settings.</value>
|
||||
</data>
|
||||
<data name="ChatBot.McpServer.Capabilities" xml:space="preserve">
|
||||
<value>Enable or disable MCP tool categories.</value>
|
||||
</data>
|
||||
<data name="ChatBot.McpServer.Enabled" xml:space="preserve">
|
||||
<value>Enable the built-in embedded MCP server bot. Server starts only after game join and stops on disconnect.</value>
|
||||
</data>
|
||||
<data name="ChatBot.McpServer.Transport.BindHost" xml:space="preserve">
|
||||
<value>IP/host to bind the embedded MCP HTTP listener to. Default is loopback only.</value>
|
||||
</data>
|
||||
<data name="ChatBot.McpServer.Transport.Port" xml:space="preserve">
|
||||
<value>TCP port for the embedded MCP HTTP listener.</value>
|
||||
</data>
|
||||
<data name="ChatBot.McpServer.Transport.Route" xml:space="preserve">
|
||||
<value>Route prefix where MCP endpoints are exposed.</value>
|
||||
</data>
|
||||
<data name="ChatBot.McpServer.Transport.RequireAuthToken" xml:space="preserve">
|
||||
<value>Require Bearer token authentication for MCP endpoint requests.</value>
|
||||
</data>
|
||||
<data name="ChatBot.McpServer.Transport.AuthTokenEnvVar" xml:space="preserve">
|
||||
<value>Environment variable name containing the MCP auth token when auth is required.</value>
|
||||
</data>
|
||||
<data name="ChatBot.McpServer.Capabilities.SessionStatus" xml:space="preserve">
|
||||
<value>Allow session and status inspection tools.</value>
|
||||
</data>
|
||||
<data name="ChatBot.McpServer.Capabilities.ChatAndCommands" xml:space="preserve">
|
||||
<value>Allow chat and internal command tools.</value>
|
||||
</data>
|
||||
<data name="ChatBot.McpServer.Capabilities.Movement" xml:space="preserve">
|
||||
<value>Allow movement and view-control tools.</value>
|
||||
</data>
|
||||
<data name="ChatBot.McpServer.Capabilities.Inventory" xml:space="preserve">
|
||||
<value>Allow inventory read and action tools.</value>
|
||||
</data>
|
||||
<data name="ChatBot.McpServer.Capabilities.EntityWorld" xml:space="preserve">
|
||||
<value>Allow entity and world inspection tools.</value>
|
||||
</data>
|
||||
</root>
|
||||
|
|
|
|||
|
|
@ -7124,6 +7124,60 @@ namespace MinecraftClient {
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Starting embedded MCP server....
|
||||
/// </summary>
|
||||
internal static string bot_mcpserver_starting {
|
||||
get {
|
||||
return ResourceManager.GetString("bot.mcpserver.starting", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Embedded MCP server started on {0}.
|
||||
/// </summary>
|
||||
internal static string bot_mcpserver_started {
|
||||
get {
|
||||
return ResourceManager.GetString("bot.mcpserver.started", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Failed to start embedded MCP server: {0}.
|
||||
/// </summary>
|
||||
internal static string bot_mcpserver_start_failed {
|
||||
get {
|
||||
return ResourceManager.GetString("bot.mcpserver.start_failed", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Embedded MCP auth token is required but environment variable {0} is empty..
|
||||
/// </summary>
|
||||
internal static string bot_mcpserver_missing_auth_token {
|
||||
get {
|
||||
return ResourceManager.GetString("bot.mcpserver.missing_auth_token", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Embedded MCP server stopped..
|
||||
/// </summary>
|
||||
internal static string bot_mcpserver_stopped {
|
||||
get {
|
||||
return ResourceManager.GetString("bot.mcpserver.stopped", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Failed to stop embedded MCP server cleanly: {0}.
|
||||
/// </summary>
|
||||
internal static string bot_mcpserver_stop_failed {
|
||||
get {
|
||||
return ResourceManager.GetString("bot.mcpserver.stop_failed", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Toggle the TUI minimap overlay, or adjust its zoom level..
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -2512,6 +2512,27 @@ see item details.</value>
|
|||
<data name="tui.crafting.grid" xml:space="preserve">
|
||||
<value>Crafting</value>
|
||||
</data>
|
||||
<data name="botname.McpServer" xml:space="preserve">
|
||||
<value>McpServer</value>
|
||||
</data>
|
||||
<data name="bot.mcpserver.starting" xml:space="preserve">
|
||||
<value>Starting embedded MCP server...</value>
|
||||
</data>
|
||||
<data name="bot.mcpserver.started" xml:space="preserve">
|
||||
<value>Embedded MCP server started on {0}</value>
|
||||
</data>
|
||||
<data name="bot.mcpserver.start_failed" xml:space="preserve">
|
||||
<value>Failed to start embedded MCP server: {0}</value>
|
||||
</data>
|
||||
<data name="bot.mcpserver.missing_auth_token" xml:space="preserve">
|
||||
<value>Embedded MCP auth token is required but environment variable {0} is empty.</value>
|
||||
</data>
|
||||
<data name="bot.mcpserver.stopped" xml:space="preserve">
|
||||
<value>Embedded MCP server stopped.</value>
|
||||
</data>
|
||||
<data name="bot.mcpserver.stop_failed" xml:space="preserve">
|
||||
<value>Failed to stop embedded MCP server cleanly: {0}</value>
|
||||
</data>
|
||||
<data name="cmd.minimap.desc" xml:space="preserve">
|
||||
<value>Toggle the TUI minimap overlay, or adjust its zoom level.</value>
|
||||
</data>
|
||||
|
|
|
|||
|
|
@ -215,6 +215,11 @@ namespace MinecraftClient.Scripting
|
|||
this.localVars = localVars;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Access the shared MCC gameplay API used by bots and the embedded MCP server.
|
||||
/// </summary>
|
||||
new public MccGameApi Game => base.Game;
|
||||
|
||||
/* == Wrappers for ChatBot API with public visibility and call limit to one per tick for safety == */
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ namespace MinecraftClient.Scripting
|
|||
private readonly List<string> registeredChatBotCommands = new();
|
||||
private readonly Lock delayTasksLock = new();
|
||||
private readonly List<TaskWithDelay> delayedTasks = new();
|
||||
private MccGameApi? _game;
|
||||
protected McClient Handler
|
||||
{
|
||||
get
|
||||
|
|
@ -60,6 +61,11 @@ namespace MinecraftClient.Scripting
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared gameplay and observed-state API used by MCP and available to built-in bots and scripts.
|
||||
/// </summary>
|
||||
protected MccGameApi Game => _game ??= new MccGameApi(() => Handler);
|
||||
|
||||
/// <summary>
|
||||
/// Will be called every client tick (~50ms at 20 TPS).
|
||||
/// </summary>
|
||||
|
|
@ -1246,6 +1252,7 @@ namespace MinecraftClient.Scripting
|
|||
protected void LookAtLocation(Location location)
|
||||
{
|
||||
Handler.UpdateLocation(Handler.GetCurrentLocation(), location);
|
||||
Handler.SendLocationUpdate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -1256,6 +1263,7 @@ namespace MinecraftClient.Scripting
|
|||
protected void LookAtLocation(float yaw, float pitch)
|
||||
{
|
||||
Handler.UpdateLocation(Handler.GetCurrentLocation(), yaw, pitch);
|
||||
Handler.SendLocationUpdate();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
1331
MinecraftClient/Scripting/MccGameApi.cs
Normal file
1331
MinecraftClient/Scripting/MccGameApi.cs
Normal file
File diff suppressed because it is too large
Load diff
216
MinecraftClient/Scripting/MccGameCommon.cs
Normal file
216
MinecraftClient/Scripting/MccGameCommon.cs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using MinecraftClient.Inventory;
|
||||
using MinecraftClient.Mapping;
|
||||
|
||||
namespace MinecraftClient.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a world coordinate rounded for tool and script consumption.
|
||||
/// </summary>
|
||||
public sealed class MccCoordinate
|
||||
{
|
||||
public required double X { get; init; }
|
||||
public required double Y { get; init; }
|
||||
public required double Z { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a block state snapshot.
|
||||
/// </summary>
|
||||
public sealed class MccBlockStateSnapshot
|
||||
{
|
||||
public required string Material { get; init; }
|
||||
public required string TypeLabel { get; init; }
|
||||
public required int BlockId { get; init; }
|
||||
public required int BlockMeta { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a simple item stack snapshot.
|
||||
/// </summary>
|
||||
public sealed class MccItemStackSnapshot
|
||||
{
|
||||
public required string Type { get; init; }
|
||||
public required int Count { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared normalization and formatting helpers for gameplay operations.
|
||||
/// </summary>
|
||||
public static class MccGameCommon
|
||||
{
|
||||
private const int CoordinateRoundingPrecision = 2;
|
||||
|
||||
public static bool TryParseItemType(string rawItemType, out ItemType itemType)
|
||||
{
|
||||
if (Enum.TryParse(rawItemType, true, out itemType) && itemType is not (ItemType.Unknown or ItemType.Null))
|
||||
return true;
|
||||
|
||||
string normalized = NormalizeToken(rawItemType);
|
||||
if (normalized.Length == 0)
|
||||
{
|
||||
itemType = ItemType.Unknown;
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (ItemType candidate in Enum.GetValues<ItemType>())
|
||||
{
|
||||
if (candidate is ItemType.Unknown or ItemType.Null)
|
||||
continue;
|
||||
|
||||
if (NormalizeToken(candidate.ToString()) == normalized)
|
||||
{
|
||||
itemType = candidate;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
itemType = ItemType.Unknown;
|
||||
return false;
|
||||
}
|
||||
|
||||
public static string NormalizeToken(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return string.Empty;
|
||||
|
||||
char[] buffer = value
|
||||
.Where(char.IsLetterOrDigit)
|
||||
.Select(char.ToLowerInvariant)
|
||||
.ToArray();
|
||||
return new string(buffer);
|
||||
}
|
||||
|
||||
public static bool TextEqualsFilter(string text, string filter)
|
||||
{
|
||||
return text.Equals(filter, StringComparison.OrdinalIgnoreCase)
|
||||
|| NormalizeToken(text) == NormalizeToken(filter);
|
||||
}
|
||||
|
||||
public static bool TextMatchesFilter(string text, string filter)
|
||||
{
|
||||
if (text.Contains(filter, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
string normalizedFilter = NormalizeToken(filter);
|
||||
if (normalizedFilter.Length == 0)
|
||||
return false;
|
||||
|
||||
return NormalizeToken(text).Contains(normalizedFilter, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public static double GetDistance(Location from, Location to)
|
||||
{
|
||||
double dx = from.X - to.X;
|
||||
double dy = from.Y - to.Y;
|
||||
double dz = from.Z - to.Z;
|
||||
return Math.Sqrt(dx * dx + dy * dy + dz * dz);
|
||||
}
|
||||
|
||||
public static double RoundCoordinate(double value)
|
||||
{
|
||||
return Math.Round(value, CoordinateRoundingPrecision, MidpointRounding.AwayFromZero);
|
||||
}
|
||||
|
||||
public static MccCoordinate ToCoordinate(Location location)
|
||||
{
|
||||
return ToCoordinate(location.X, location.Y, location.Z);
|
||||
}
|
||||
|
||||
public static MccCoordinate ToCoordinate(double x, double y, double z)
|
||||
{
|
||||
return new MccCoordinate
|
||||
{
|
||||
X = RoundCoordinate(x),
|
||||
Y = RoundCoordinate(y),
|
||||
Z = RoundCoordinate(z)
|
||||
};
|
||||
}
|
||||
|
||||
public static Location ToBlockLocation(double x, double y, double z)
|
||||
{
|
||||
return new Location(Math.Floor(x), Math.Floor(y), Math.Floor(z));
|
||||
}
|
||||
|
||||
public static MccBlockStateSnapshot ToBlockState(Block block)
|
||||
{
|
||||
return new MccBlockStateSnapshot
|
||||
{
|
||||
Material = block.Type.ToString(),
|
||||
TypeLabel = block.GetTypeString(),
|
||||
BlockId = block.BlockId,
|
||||
BlockMeta = block.BlockMeta
|
||||
};
|
||||
}
|
||||
|
||||
public static object? DescribeMetadataValue(object? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => null,
|
||||
string s => s,
|
||||
bool b => b,
|
||||
byte b => b,
|
||||
sbyte b => b,
|
||||
short s => s,
|
||||
ushort s => s,
|
||||
int i => i,
|
||||
uint i => i,
|
||||
long l => l,
|
||||
ulong l => l,
|
||||
float f => f,
|
||||
double d => d,
|
||||
decimal d => d,
|
||||
Enum e => e.ToString(),
|
||||
Location location => ToCoordinate(location),
|
||||
Item item => ToItemStack(item),
|
||||
byte[] data => new { bytes = data.Length },
|
||||
_ => value.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
public static MccItemStackSnapshot ToItemStack(Item item)
|
||||
{
|
||||
return new MccItemStackSnapshot
|
||||
{
|
||||
Type = item.Type.ToString(),
|
||||
Count = item.Count
|
||||
};
|
||||
}
|
||||
|
||||
public static bool TryParseBlockQuery(string? query, out int? blockId, out int? blockMeta)
|
||||
{
|
||||
blockId = null;
|
||||
blockMeta = null;
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return false;
|
||||
|
||||
string trimmed = query.Trim();
|
||||
int separator = trimmed.IndexOf(':');
|
||||
if (separator >= 0)
|
||||
{
|
||||
string idPart = trimmed[..separator].Trim();
|
||||
string metaPart = trimmed[(separator + 1)..].Trim();
|
||||
if (int.TryParse(idPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedId))
|
||||
{
|
||||
blockId = parsedId;
|
||||
if (int.TryParse(metaPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedMeta))
|
||||
blockMeta = parsedMeta;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (int.TryParse(trimmed, NumberStyles.Integer, CultureInfo.InvariantCulture, out int blockStateId))
|
||||
{
|
||||
blockId = blockStateId;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
319
MinecraftClient/Scripting/MccGameModels.cs
Normal file
319
MinecraftClient/Scripting/MccGameModels.cs
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace MinecraftClient.Scripting;
|
||||
|
||||
public sealed class MccRecentEventsResult
|
||||
{
|
||||
public required long AfterId { get; init; }
|
||||
public required long LatestId { get; init; }
|
||||
public required int Count { get; init; }
|
||||
public required MccRecentEventEntry[] Events { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccChatHistoryResult
|
||||
{
|
||||
public required int Count { get; init; }
|
||||
public required MccChatHistoryEntry[] Entries { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccPathPreviewResult
|
||||
{
|
||||
public required bool PathFound { get; init; }
|
||||
public required bool ExactReachable { get; init; }
|
||||
public required MccCoordinate Target { get; init; }
|
||||
public required MccCoordinate StartLocation { get; init; }
|
||||
public MccCoordinate? FinalWaypoint { get; init; }
|
||||
public double? FinalDistance { get; init; }
|
||||
public required int WaypointCount { get; init; }
|
||||
public required bool Truncated { get; init; }
|
||||
public required MccCoordinate[] Waypoints { get; init; }
|
||||
public required bool AllowUnsafe { get; init; }
|
||||
public required int MaxOffset { get; init; }
|
||||
public required int MinOffset { get; init; }
|
||||
public required int TimeoutMs { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccReachabilityResult
|
||||
{
|
||||
public required bool Reachable { get; init; }
|
||||
public required bool ExactReachable { get; init; }
|
||||
public required MccCoordinate Target { get; init; }
|
||||
public required MccCoordinate StartLocation { get; init; }
|
||||
public MccCoordinate? FinalWaypoint { get; init; }
|
||||
public double? FinalDistance { get; init; }
|
||||
public required int WaypointCount { get; init; }
|
||||
public required bool AllowUnsafe { get; init; }
|
||||
public required int MaxOffset { get; init; }
|
||||
public required int MinOffset { get; init; }
|
||||
public required int TimeoutMs { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccPlayersDetailedEntry
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public required Guid Uuid { get; init; }
|
||||
public required int Ping { get; init; }
|
||||
public required int Gamemode { get; init; }
|
||||
public required bool Listed { get; init; }
|
||||
public string? DisplayName { get; init; }
|
||||
public int? EntityId { get; init; }
|
||||
public double? X { get; init; }
|
||||
public double? Y { get; init; }
|
||||
public double? Z { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccPlayersDetailedResult
|
||||
{
|
||||
public required bool IncludeSelf { get; init; }
|
||||
public required bool IncludeCoordinates { get; init; }
|
||||
public required int Count { get; init; }
|
||||
public required MccPlayersDetailedEntry[] Players { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccNearbyPlayerEntry
|
||||
{
|
||||
public required int EntityId { get; init; }
|
||||
public required Guid Uuid { get; init; }
|
||||
public string? Name { get; init; }
|
||||
public string? CustomName { get; init; }
|
||||
public required double X { get; init; }
|
||||
public required double Y { get; init; }
|
||||
public required double Z { get; init; }
|
||||
public required double Distance { get; init; }
|
||||
public required int Latency { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccPlayerNearbyResult
|
||||
{
|
||||
public required double Radius { get; init; }
|
||||
public string? PlayerName { get; init; }
|
||||
public required bool IncludeSelf { get; init; }
|
||||
public required bool AnyNearby { get; init; }
|
||||
public required int Count { get; init; }
|
||||
public required MccNearbyPlayerEntry[] Players { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccLocatedPlayerResult
|
||||
{
|
||||
public required string PlayerName { get; init; }
|
||||
public string? MatchedName { get; init; }
|
||||
public required int EntityId { get; init; }
|
||||
public required Guid Uuid { get; init; }
|
||||
public required double X { get; init; }
|
||||
public required double Y { get; init; }
|
||||
public required double Z { get; init; }
|
||||
public required double Distance { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccEntitySummary
|
||||
{
|
||||
public required int Id { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public required string TypeLabel { get; init; }
|
||||
public required Guid Uuid { get; init; }
|
||||
public string? Name { get; init; }
|
||||
public string? CustomName { get; init; }
|
||||
public required double X { get; init; }
|
||||
public required double Y { get; init; }
|
||||
public required double Z { get; init; }
|
||||
public double? Distance { get; init; }
|
||||
public float? Health { get; init; }
|
||||
public string? Pose { get; init; }
|
||||
public int? Latency { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccQueryEntitiesResult
|
||||
{
|
||||
public required int Count { get; init; }
|
||||
public required MccEntitySummary[] Entities { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccListEntitiesResult
|
||||
{
|
||||
public required int TotalTracked { get; init; }
|
||||
public required int Count { get; init; }
|
||||
public required MccEntitySummary[] Entities { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccEntityEquipmentEntry
|
||||
{
|
||||
public required int Slot { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public required int Count { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccEffectSnapshot
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public string? Name { get; init; }
|
||||
public required int Amplifier { get; init; }
|
||||
public required int RemainingSeconds { get; init; }
|
||||
public required bool IsInfinite { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccEntityInfoResult
|
||||
{
|
||||
public required int Id { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public required string TypeLabel { get; init; }
|
||||
public required Guid Uuid { get; init; }
|
||||
public string? Name { get; init; }
|
||||
public string? CustomName { get; init; }
|
||||
public required bool CustomNameVisible { get; init; }
|
||||
public required double X { get; init; }
|
||||
public required double Y { get; init; }
|
||||
public required double Z { get; init; }
|
||||
public required float Yaw { get; init; }
|
||||
public required float Pitch { get; init; }
|
||||
public required float Health { get; init; }
|
||||
public string? Pose { get; init; }
|
||||
public int? Latency { get; init; }
|
||||
public int? ObjectData { get; init; }
|
||||
public Dictionary<string, object?>? Metadata { get; init; }
|
||||
public MccEntityEquipmentEntry[]? Equipment { get; init; }
|
||||
public MccEffectSnapshot[]? ActiveEffects { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccMoveToPlayerTarget
|
||||
{
|
||||
public string? PlayerName { get; init; }
|
||||
public required int EntityId { get; init; }
|
||||
public required double X { get; init; }
|
||||
public required double Y { get; init; }
|
||||
public required double Z { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccMoveToPlayerResult
|
||||
{
|
||||
public required bool PathFound { get; init; }
|
||||
public required bool Arrived { get; init; }
|
||||
public required double Tolerance { get; init; }
|
||||
public required int VerifyWaitMs { get; init; }
|
||||
public required MccMoveToPlayerTarget Target { get; init; }
|
||||
public required MccCoordinate StartLocation { get; init; }
|
||||
public required MccCoordinate FinalLocation { get; init; }
|
||||
public required double FinalDistance { get; init; }
|
||||
public required double DistanceMoved { get; init; }
|
||||
public required bool AllowUnsafe { get; init; }
|
||||
public required bool AllowDirectTeleport { get; init; }
|
||||
public required int MaxOffset { get; init; }
|
||||
public required int MinOffset { get; init; }
|
||||
public required int TimeoutMs { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccHotbarSelectionResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public required string ItemType { get; init; }
|
||||
public required int InventorySlot { get; init; }
|
||||
public required int SelectedSlot { get; init; }
|
||||
public required int Count { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccInventorySnapshotSlot
|
||||
{
|
||||
public required int Slot { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public required int Count { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccInventorySnapshotResult
|
||||
{
|
||||
public required int Id { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public required int SlotCount { get; init; }
|
||||
public required MccInventorySnapshotSlot[] Slots { get; init; }
|
||||
public MccItemStackSnapshot? Cursor { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccInventorySearchMatch
|
||||
{
|
||||
public required int InventoryId { get; init; }
|
||||
public required string InventoryType { get; init; }
|
||||
public required string InventoryTitle { get; init; }
|
||||
public required int Slot { get; init; }
|
||||
public required string ItemType { get; init; }
|
||||
public required string TypeLabel { get; init; }
|
||||
public required int Count { get; init; }
|
||||
public required bool IsPlayerInventory { get; init; }
|
||||
public int? HotbarSlot { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccInventorySearchResult
|
||||
{
|
||||
public required string Query { get; init; }
|
||||
public required bool ExactMatch { get; init; }
|
||||
public required bool IncludeContainers { get; init; }
|
||||
public required int Count { get; init; }
|
||||
public required MccInventorySearchMatch[] Matches { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccInventoryListEntry
|
||||
{
|
||||
public required int Id { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public required int SlotCount { get; init; }
|
||||
public required int NonEmptySlots { get; init; }
|
||||
public required bool Active { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccInventoryListResult
|
||||
{
|
||||
public required int Count { get; init; }
|
||||
public required MccInventoryListEntry[] Inventories { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccItemEntityEntry
|
||||
{
|
||||
public required int EntityId { get; init; }
|
||||
public required string ItemType { get; init; }
|
||||
public required string TypeLabel { get; init; }
|
||||
public required int Count { get; init; }
|
||||
public required double X { get; init; }
|
||||
public required double Y { get; init; }
|
||||
public required double Z { get; init; }
|
||||
public required double Distance { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccItemEntitiesResult
|
||||
{
|
||||
public string? ItemType { get; init; }
|
||||
public required double Radius { get; init; }
|
||||
public required int Count { get; init; }
|
||||
public required MccItemEntityEntry[] Items { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccPickupAttempt
|
||||
{
|
||||
public required int EntityId { get; init; }
|
||||
public required string ItemType { get; init; }
|
||||
public required string TypeLabel { get; init; }
|
||||
public required int ExpectedCount { get; init; }
|
||||
public required MccCoordinate Target { get; init; }
|
||||
public required bool PathFound { get; init; }
|
||||
public required bool Arrived { get; init; }
|
||||
public required bool EntityGone { get; init; }
|
||||
public required int InventoryDelta { get; init; }
|
||||
public required MccCoordinate StartLocation { get; init; }
|
||||
public required MccCoordinate FinalLocation { get; init; }
|
||||
public required double FinalDistance { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MccPickupItemsResult
|
||||
{
|
||||
public required string ItemType { get; init; }
|
||||
public required double Radius { get; init; }
|
||||
public required int MaxItems { get; init; }
|
||||
public required bool AllowUnsafe { get; init; }
|
||||
public required int TimeoutMs { get; init; }
|
||||
public required int Attempted { get; init; }
|
||||
public required int SuccessfulPickups { get; init; }
|
||||
public required int CollectedCount { get; init; }
|
||||
public int? InitialInventoryCount { get; init; }
|
||||
public int? FinalInventoryCount { get; init; }
|
||||
public required int RemainingNearby { get; init; }
|
||||
public required MccPickupAttempt[] Attempts { get; init; }
|
||||
}
|
||||
63
MinecraftClient/Scripting/MccGameResult.cs
Normal file
63
MinecraftClient/Scripting/MccGameResult.cs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
using System;
|
||||
|
||||
namespace MinecraftClient.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the outcome of a shared MCC game operation.
|
||||
/// </summary>
|
||||
public class MccGameResult
|
||||
{
|
||||
public bool Success { get; init; }
|
||||
public string? ErrorCode { get; init; }
|
||||
public string? Message { get; init; }
|
||||
|
||||
public static MccGameResult Ok(string? message = null)
|
||||
{
|
||||
return new MccGameResult
|
||||
{
|
||||
Success = true,
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
|
||||
public static MccGameResult Fail(string errorCode, string? message = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(errorCode);
|
||||
return new MccGameResult
|
||||
{
|
||||
Success = false,
|
||||
ErrorCode = errorCode,
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the outcome of a shared MCC game operation with typed payload data.
|
||||
/// </summary>
|
||||
public sealed class MccGameResult<T> : MccGameResult
|
||||
{
|
||||
public T? Data { get; init; }
|
||||
|
||||
public static MccGameResult<T> Ok(T? data, string? message = null)
|
||||
{
|
||||
return new MccGameResult<T>
|
||||
{
|
||||
Success = true,
|
||||
Data = data,
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
|
||||
public static MccGameResult<T> Fail(string errorCode, string? message = null, T? data = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(errorCode);
|
||||
return new MccGameResult<T>
|
||||
{
|
||||
Success = false,
|
||||
ErrorCode = errorCode,
|
||||
Message = message,
|
||||
Data = data
|
||||
};
|
||||
}
|
||||
}
|
||||
205
MinecraftClient/Scripting/MccObservedState.cs
Normal file
205
MinecraftClient/Scripting/MccObservedState.cs
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace MinecraftClient.Scripting;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a recent high-signal runtime event observed by MCC.
|
||||
/// </summary>
|
||||
public sealed class MccRecentEventEntry
|
||||
{
|
||||
public required long Id { get; init; }
|
||||
public required DateTimeOffset TimestampUtc { get; init; }
|
||||
public required string Type { get; init; }
|
||||
public object? Data { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a recent chat or system line observed by MCC.
|
||||
/// </summary>
|
||||
public sealed class MccChatHistoryEntry
|
||||
{
|
||||
public required DateTimeOffset TimestampUtc { get; init; }
|
||||
public required string Kind { get; init; }
|
||||
public required string Text { get; init; }
|
||||
public string? Sender { get; init; }
|
||||
public string? Message { get; init; }
|
||||
public string? Json { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents the latest observed world time and weather values.
|
||||
/// </summary>
|
||||
public sealed class MccRuntimeStateSnapshot
|
||||
{
|
||||
public long? WorldAge { get; init; }
|
||||
public long? TimeOfDay { get; init; }
|
||||
public float? RainLevel { get; init; }
|
||||
public float? ThunderLevel { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared observed-state store populated from ChatBot callbacks and consumed by MCP and bots/scripts.
|
||||
/// </summary>
|
||||
public static class MccObservedStateStore
|
||||
{
|
||||
private const int MaxEntries = 500;
|
||||
|
||||
private static readonly Lock s_recentEventsLock = new();
|
||||
private static readonly Lock s_chatHistoryLock = new();
|
||||
private static readonly Lock s_runtimeStateLock = new();
|
||||
|
||||
private static readonly List<MccRecentEventEntry> s_recentEvents = [];
|
||||
private static readonly List<MccChatHistoryEntry> s_chatHistory = [];
|
||||
|
||||
private static long s_nextRecentEventId = 1;
|
||||
private static long? s_worldAge;
|
||||
private static long? s_timeOfDay;
|
||||
private static float? s_rainLevel;
|
||||
private static float? s_thunderLevel;
|
||||
|
||||
public static long AddRecentEvent(string type, object? data = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(type);
|
||||
|
||||
lock (s_recentEventsLock)
|
||||
{
|
||||
long id = s_nextRecentEventId++;
|
||||
s_recentEvents.Add(new MccRecentEventEntry
|
||||
{
|
||||
Id = id,
|
||||
TimestampUtc = DateTimeOffset.UtcNow,
|
||||
Type = type,
|
||||
Data = data
|
||||
});
|
||||
|
||||
TrimToMaxEntries(s_recentEvents);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
public static long GetLatestRecentEventId()
|
||||
{
|
||||
lock (s_recentEventsLock)
|
||||
{
|
||||
return s_recentEvents.Count > 0 ? s_recentEvents[^1].Id : 0;
|
||||
}
|
||||
}
|
||||
|
||||
public static MccRecentEventEntry[] GetRecentEventsAfter(long afterId, int maxCount, string? typeFilter = null)
|
||||
{
|
||||
int count = Math.Clamp(maxCount, 1, MaxEntries);
|
||||
string? normalizedFilter = string.IsNullOrWhiteSpace(typeFilter) ? null : typeFilter.Trim();
|
||||
|
||||
lock (s_recentEventsLock)
|
||||
{
|
||||
return s_recentEvents
|
||||
.Where(entry => entry.Id > afterId)
|
||||
.Where(entry => normalizedFilter is null
|
||||
|| entry.Type.Contains(normalizedFilter, StringComparison.OrdinalIgnoreCase))
|
||||
.Take(count)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public static void AddChatHistoryEntry(MccChatHistoryEntry entry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
lock (s_chatHistoryLock)
|
||||
{
|
||||
s_chatHistory.Add(entry);
|
||||
TrimToMaxEntries(s_chatHistory);
|
||||
}
|
||||
}
|
||||
|
||||
public static MccChatHistoryEntry[] GetLatestChatHistory(int maxCount)
|
||||
{
|
||||
int count = Math.Clamp(maxCount, 1, MaxEntries);
|
||||
lock (s_chatHistoryLock)
|
||||
{
|
||||
return s_chatHistory.TakeLast(count).ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
public static void SetTime(long worldAge, long timeOfDay)
|
||||
{
|
||||
lock (s_runtimeStateLock)
|
||||
{
|
||||
s_worldAge = worldAge;
|
||||
s_timeOfDay = timeOfDay;
|
||||
}
|
||||
}
|
||||
|
||||
public static void SetRainLevel(float level)
|
||||
{
|
||||
lock (s_runtimeStateLock)
|
||||
{
|
||||
s_rainLevel = level;
|
||||
}
|
||||
}
|
||||
|
||||
public static void SetThunderLevel(float level)
|
||||
{
|
||||
lock (s_runtimeStateLock)
|
||||
{
|
||||
s_thunderLevel = level;
|
||||
}
|
||||
}
|
||||
|
||||
public static MccRuntimeStateSnapshot GetRuntimeStateSnapshot()
|
||||
{
|
||||
lock (s_runtimeStateLock)
|
||||
{
|
||||
return new MccRuntimeStateSnapshot
|
||||
{
|
||||
WorldAge = s_worldAge,
|
||||
TimeOfDay = s_timeOfDay,
|
||||
RainLevel = s_rainLevel,
|
||||
ThunderLevel = s_thunderLevel
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static void ClearRecentEvents()
|
||||
{
|
||||
lock (s_recentEventsLock)
|
||||
{
|
||||
s_recentEvents.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public static void ClearChatHistory()
|
||||
{
|
||||
lock (s_chatHistoryLock)
|
||||
{
|
||||
s_chatHistory.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
public static void ClearRuntimeState()
|
||||
{
|
||||
lock (s_runtimeStateLock)
|
||||
{
|
||||
s_worldAge = null;
|
||||
s_timeOfDay = null;
|
||||
s_rainLevel = null;
|
||||
s_thunderLevel = null;
|
||||
}
|
||||
}
|
||||
|
||||
public static void ClearAll()
|
||||
{
|
||||
ClearChatHistory();
|
||||
ClearRuntimeState();
|
||||
ClearRecentEvents();
|
||||
}
|
||||
|
||||
private static void TrimToMaxEntries<T>(List<T> entries)
|
||||
{
|
||||
if (entries.Count > MaxEntries)
|
||||
entries.RemoveRange(0, entries.Count - MaxEntries);
|
||||
}
|
||||
}
|
||||
|
|
@ -1811,6 +1811,17 @@ namespace MinecraftClient
|
|||
get { return ChatBots.DiscordRpc.Config; }
|
||||
set { ChatBots.DiscordRpc.Config = value; ChatBots.DiscordRpc.Config.OnSettingUpdate(); }
|
||||
}
|
||||
|
||||
[TomlPrecedingComment("$ChatBot.McpServer$")]
|
||||
public ChatBots.McpServer.Configs McpServer
|
||||
{
|
||||
get { return ChatBots.McpServer.Config; }
|
||||
set
|
||||
{
|
||||
ChatBots.McpServer.Config = value ?? new ChatBots.McpServer.Configs();
|
||||
ChatBots.McpServer.Config.OnSettingUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ redirectFrom:
|
|||
- [Follow player](#follow-player)
|
||||
- [Hangman](#hangman)
|
||||
- [Mailer](#mailer)
|
||||
- [MCP Server](#mcp-server)
|
||||
- [Map](#map)
|
||||
- [PlayerList Logger](#playerlist-logger)
|
||||
- [Remote Control](#remote-control)
|
||||
|
|
@ -2327,6 +2328,447 @@ redirectFrom:
|
|||
|
||||
- **Default:** `30`
|
||||
|
||||
## MCP Server
|
||||
|
||||
- **Description:**
|
||||
|
||||
This lets you control MCC from an AI agent such as Claude Code, Codex, Cursor, OpenCode and others.
|
||||
|
||||
Once enabled, your AI client can connect to the running MCC session and ask it to do things like read chat, check nearby players, move around, inspect inventories, or interact with entities.
|
||||
|
||||
The MCP server starts after MCC joins the game. By default it listens on `http://127.0.0.1:33333/mcp`.
|
||||
|
||||
This bot does not log in to Minecraft on its own. MCC still connects to the server normally first. The MCP part simply gives your AI agent a way to use the session that is already running.
|
||||
|
||||
We recommend protecting it with an auth token, even for local use.
|
||||
|
||||
<div class="custom-container warning"><p class="custom-container-title">Warning</p>
|
||||
|
||||
This feature is new and experimental, it still requires a lot of testing and polish. Please leave your feedback on our Discord server.
|
||||
|
||||
</div>
|
||||
|
||||
<div class="custom-container warning"><p class="custom-container-title">Warning</p>
|
||||
|
||||
The performance of task execution depends on how smart the LLM (Large Language Model) (e.g. Sonnet 4.6, GPT 5.4) and Harness/AI Agent (e.g. Claude Code) are.
|
||||
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary><strong>Recommended harnesses / agents</strong></summary>
|
||||
|
||||
- Claude Code
|
||||
- Codex
|
||||
- OpenCode
|
||||
- Cursor
|
||||
- Open Claw
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Recommended models</strong></summary>
|
||||
|
||||
Model rankings are based on the [Agentic Intelligence Benchmark](https://artificialanalysis.ai/?intelligence=agentic-index).
|
||||
|
||||
**Frontier (best results, higher cost):**
|
||||
- [Claude Opus 4.6](https://openrouter.ai/anthropic/claude-opus-4.6)
|
||||
- [GPT 5.4](https://openrouter.ai/openai/gpt-5.4)
|
||||
- [GLM-5](https://openrouter.ai/z-ai/glm-5) -- cheapest in this tier
|
||||
|
||||
**Open-source (close to frontier, lower cost):**
|
||||
- [MiMo-V2-Pro](https://openrouter.ai/xiaomi/mimo-v2-pro)
|
||||
- [MiniMax M2.7](https://openrouter.ai/minimax/minimax-m2.7) -- cheapest in this tier
|
||||
- [Kimi K2.5](https://openrouter.ai/moonshotai/kimi-k2.5)
|
||||
|
||||
**Budget (good for medium and simpler tasks):**
|
||||
- [Google Gemini 3 Flash](https://openrouter.ai/google/gemini-3-flash-preview) -- recommended
|
||||
- NVIDIA Nemotron 3 Super: [free tier](https://openrouter.ai/nvidia/nemotron-3-super-120b-a12b:free) / [paid tier](https://openrouter.ai/nvidia/nemotron-3-super-120b-a12b) / [NVIDIA Build](https://build.nvidia.com/nvidia/nemotron-3-super-120b-a12b) -- free tier may hit rate limits; your data is used for model training
|
||||
- [DeepSeek V3.2](https://openrouter.ai/deepseek/deepseek-v3.2)
|
||||
|
||||
**Ultra-budget (for very simple tasks):**
|
||||
- [Google Gemini 3.1 Flash Lite Preview](https://openrouter.ai/google/gemini-3.1-flash-lite-preview)
|
||||
|
||||
</details>
|
||||
|
||||
- **Settings:**
|
||||
|
||||
**Section:** **`ChatBot.McpServer`**
|
||||
|
||||
<details>
|
||||
<summary><strong>Quick start</strong></summary>
|
||||
|
||||
1. Enable `ChatBot.McpServer`.
|
||||
2. Set `Transport.RequireAuthToken = true`.
|
||||
3. Export a token before starting MCC:
|
||||
|
||||
```bash
|
||||
export MCC_MCP_AUTH_TOKEN="replace-me"
|
||||
```
|
||||
|
||||
4. Leave `Transport.BindHost = "127.0.0.1"` unless you intentionally want to expose the endpoint to other machines.
|
||||
5. Start MCC and join a Minecraft server.
|
||||
6. Connect your AI client to `http://127.0.0.1:33333/mcp`.
|
||||
|
||||
The embedded host starts after the bot joins the game, not at process launch.
|
||||
|
||||
Tool availability still depends on MCC runtime support. Movement, inventory, and entity tools need the corresponding MCC features to be available for the current version and session.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Recommended config</strong></summary>
|
||||
|
||||
```toml
|
||||
[ChatBot.McpServer]
|
||||
Enabled = true
|
||||
Transport = { BindHost = "127.0.0.1", Port = 33333, Route = "/mcp", RequireAuthToken = true, AuthTokenEnvVar = "MCC_MCP_AUTH_TOKEN" }
|
||||
Capabilities = { SessionStatus = true, ChatAndCommands = true, Movement = true, Inventory = true, EntityWorld = true }
|
||||
```
|
||||
|
||||
For a local setup, keeping the bind host on `127.0.0.1` is the safest default.
|
||||
|
||||
We recommend enabling bearer-token protection and exporting the token before starting MCC:
|
||||
|
||||
```bash
|
||||
export MCC_MCP_AUTH_TOKEN="replace-me"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>All settings</strong></summary>
|
||||
|
||||
#### `Enabled`
|
||||
|
||||
- **Description:**
|
||||
|
||||
Enables or disables the built-in MCP server bot.
|
||||
|
||||
- **Type:** `boolean`
|
||||
|
||||
- **Default:** `false`
|
||||
|
||||
#### `Transport.BindHost`
|
||||
|
||||
- **Description:**
|
||||
|
||||
Hostname or IP address the embedded HTTP server binds to.
|
||||
|
||||
Use `127.0.0.1` for local-only access. Binding to `0.0.0.0` exposes the endpoint to your network.
|
||||
|
||||
- **Type:** `string`
|
||||
|
||||
- **Default:** `127.0.0.1`
|
||||
|
||||
#### `Transport.Port`
|
||||
|
||||
- **Description:**
|
||||
|
||||
TCP port used by the embedded HTTP MCP server.
|
||||
|
||||
- **Type:** `integer`
|
||||
|
||||
- **Default:** `33333`
|
||||
|
||||
#### `Transport.Route`
|
||||
|
||||
- **Description:**
|
||||
|
||||
HTTP path used by the MCP endpoint.
|
||||
|
||||
- **Type:** `string`
|
||||
|
||||
- **Default:** `/mcp`
|
||||
|
||||
#### `Transport.RequireAuthToken`
|
||||
|
||||
- **Description:**
|
||||
|
||||
Require an `Authorization: Bearer ...` header before clients can use the server.
|
||||
|
||||
Recommended: `true`
|
||||
|
||||
- **Type:** `boolean`
|
||||
|
||||
- **Default:** `false`
|
||||
|
||||
#### `Transport.AuthTokenEnvVar`
|
||||
|
||||
- **Description:**
|
||||
|
||||
Environment variable name MCC reads when bearer-token auth is enabled.
|
||||
|
||||
- **Type:** `string`
|
||||
|
||||
- **Default:** `MCC_MCP_AUTH_TOKEN`
|
||||
|
||||
#### `Capabilities.SessionStatus`
|
||||
|
||||
- **Description:**
|
||||
|
||||
Enables session, server, chat-history, event-history, reference, and status-reporting tools.
|
||||
|
||||
- **Type:** `boolean`
|
||||
|
||||
- **Default:** `true`
|
||||
|
||||
#### `Capabilities.ChatAndCommands`
|
||||
|
||||
- **Description:**
|
||||
|
||||
Enables chat sending, internal command execution, respawn, disconnect, quit, and related direct-control tools.
|
||||
|
||||
- **Type:** `boolean`
|
||||
|
||||
- **Default:** `true`
|
||||
|
||||
#### `Capabilities.Movement`
|
||||
|
||||
- **Description:**
|
||||
|
||||
Enables movement, path preview, look, reachability, and nearby block/player queries.
|
||||
|
||||
- **Type:** `boolean`
|
||||
|
||||
- **Default:** `true`
|
||||
|
||||
#### `Capabilities.Inventory`
|
||||
|
||||
- **Description:**
|
||||
|
||||
Enables inventory snapshots, container open/close, window actions, item selection, item use, drop, deposit, and withdraw tools.
|
||||
|
||||
- **Type:** `boolean`
|
||||
|
||||
- **Default:** `true`
|
||||
|
||||
#### `Capabilities.EntityWorld`
|
||||
|
||||
- **Description:**
|
||||
|
||||
Enables entity queries, sign search, block lookup, nearby dropped-item listing, and item pickup helpers.
|
||||
|
||||
- **Type:** `boolean`
|
||||
|
||||
- **Default:** `true`
|
||||
|
||||
</details>
|
||||
|
||||
The client examples below assume the default local endpoint `http://127.0.0.1:33333/mcp`.
|
||||
|
||||
The recommended setup is to keep `RequireAuthToken = true`. If you temporarily disable auth for local testing, you can omit the auth-header parts.
|
||||
|
||||
<details>
|
||||
<summary><strong>Claude Code</strong></summary>
|
||||
|
||||
Official docs: [Claude Code MCP](https://code.claude.com/docs/en/mcp)
|
||||
|
||||
These commands assume the default local MCC endpoint:
|
||||
|
||||
```bash
|
||||
claude mcp add --transport http mcc http://127.0.0.1:33333/mcp
|
||||
claude mcp list
|
||||
```
|
||||
|
||||
If you enabled bearer-token auth in MCC:
|
||||
|
||||
```bash
|
||||
claude mcp add --transport http mcc http://127.0.0.1:33333/mcp \
|
||||
--header "Authorization: Bearer $MCC_MCP_AUTH_TOKEN"
|
||||
```
|
||||
|
||||
You can check the server status inside Claude Code with `/mcp`.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Codex</strong></summary>
|
||||
|
||||
Official docs: [OpenAI Docs MCP quickstart](https://developers.openai.com/learn/docs-mcp)
|
||||
|
||||
Add the MCC server with the Codex CLI:
|
||||
|
||||
```bash
|
||||
codex mcp add mcc --url http://127.0.0.1:33333/mcp
|
||||
codex mcp list
|
||||
```
|
||||
|
||||
You can also place it directly in `~/.codex/config.toml`:
|
||||
|
||||
```toml
|
||||
[mcp_servers.mcc]
|
||||
url = "http://127.0.0.1:33333/mcp"
|
||||
```
|
||||
|
||||
If your Codex setup supports custom MCP headers, add the same `Authorization: Bearer ...` header you use in the other examples.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Cursor</strong></summary>
|
||||
|
||||
Official docs: [Cursor MCP docs](https://docs.cursor.com/en/tools/mcp)
|
||||
|
||||
Create `~/.cursor/mcp.json` and add:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mcc": {
|
||||
"url": "http://127.0.0.1:33333/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Restart Cursor after saving the file.
|
||||
|
||||
If your Cursor setup uses auth headers for remote MCP servers, add `Authorization: Bearer ...` with your MCC token.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>OpenCode</strong></summary>
|
||||
|
||||
Official docs: [OpenCode MCP servers](https://opencode.ai/docs/mcp-servers)
|
||||
|
||||
Put this in `~/.config/opencode/opencode.json` for a global setup, or in `opencode.json` in your project root:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"mcc": {
|
||||
"type": "remote",
|
||||
"url": "http://127.0.0.1:33333/mcp",
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you enabled bearer-token auth in MCC, add headers like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"mcp": {
|
||||
"mcc": {
|
||||
"type": "remote",
|
||||
"url": "http://127.0.0.1:33333/mcp",
|
||||
"enabled": true,
|
||||
"oauth": false,
|
||||
"headers": {
|
||||
"Authorization": "Bearer {env:MCC_MCP_AUTH_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Available MCP tools</strong></summary>
|
||||
|
||||
These are the tools currently exposed by `ChatBot.McpServer`.
|
||||
|
||||
**Session, status, and reference**
|
||||
|
||||
- `mcc_session_status`: Get the current MCC session state and feature availability.
|
||||
- `mcc_server_info`: Get the active server connection info and current TPS.
|
||||
- `mcc_player_state`: Get the current controlled-player state.
|
||||
- `mcc_world_state`: Get world state, chunk loading progress, and last observed time and weather values.
|
||||
- `mcc_chunk_status`: Check chunk loading status for the player location or a target coordinate.
|
||||
- `mcc_player_stats`: Get player stats, orientation, and current location.
|
||||
- `mcc_status_effects`: List active player status effects.
|
||||
- `mcc_loaded_bots`: List loaded built-in bots and scripts.
|
||||
- `mcc_internal_commands_list`: List available MCC internal commands.
|
||||
- `mcc_agent_guidance`: Return the MCC MCP operator prompt bundle for external agents.
|
||||
- `mcc_materials_list`: List known MCC material names.
|
||||
- `mcc_block_types_list`: List known MCC block types.
|
||||
- `mcc_entity_types_list`: List known MCC entity types.
|
||||
- `mcc_world_block_at`: Get block information at a world coordinate.
|
||||
|
||||
**Observed state**
|
||||
|
||||
- `mcc_recent_events`: Read recent high-signal runtime events such as weather, titles, death, respawn, inventory open/close, and joins/leaves.
|
||||
- `mcc_chat_history`: Read recent chat and system lines seen by MCC.
|
||||
- `mcc_players_list`: List online players known to MCC.
|
||||
- `mcc_players_detailed`: List tracked players with UUID, latency, gamemode, and coordinates when available.
|
||||
|
||||
**Chat and direct control**
|
||||
|
||||
- `mcc_send_chat`: Send chat text or a slash command to the server.
|
||||
- `mcc_quit_client`: Quit MCC cleanly.
|
||||
- `mcc_disconnect`: Disconnect MCC from the server without quitting the process.
|
||||
- `mcc_respawn`: Respawn the controlled player when dead.
|
||||
- `mcc_run_internal_command`: Run an MCC internal command.
|
||||
- `mcc_animation`: Play a hand-swing animation.
|
||||
- `mcc_toggle_sneak`: Start or stop sneaking.
|
||||
- `mcc_toggle_sprint`: Start or stop sprinting.
|
||||
- `mcc_change_hotbar_slot`: Change the active hotbar slot.
|
||||
- `mcc_select_item`: Select a hotbar item by item type.
|
||||
- `mcc_use_item_on_hand`: Use the currently held item.
|
||||
- `mcc_use_item_on_block`: Use the held item on a target block.
|
||||
- `mcc_dig_block`: Dig a block at a target location.
|
||||
- `mcc_place_block`: Place the currently held block or item at a target location.
|
||||
|
||||
**Movement, view, and nearby queries**
|
||||
|
||||
- `mcc_raycast_block`: Raycast from the player's view and return the first non-air block hit.
|
||||
- `mcc_path_preview`: Compute a path without moving there.
|
||||
- `mcc_block_scan`: Scan nearby blocks around the player.
|
||||
- `mcc_blocks_find`: Find nearby blocks by name, type, or ID.
|
||||
- `mcc_player_nearby`: Check whether a player is nearby.
|
||||
- `mcc_player_locate`: Locate a tracked player by name.
|
||||
- `mcc_entity_nearest`: Find the nearest tracked entity that matches the requested filters.
|
||||
- `mcc_can_reach_position`: Check whether MCC can path to a coordinate without moving there.
|
||||
- `mcc_move_to`: Path to a world coordinate and verify arrival.
|
||||
- `mcc_move_to_player`: Locate a tracked player, move to them, and verify arrival.
|
||||
- `mcc_look_at`: Rotate the view toward a world coordinate.
|
||||
- `mcc_look_direction`: Rotate the view to a cardinal direction or straight up/down.
|
||||
- `mcc_look_angles`: Rotate the view to explicit yaw and pitch angles.
|
||||
|
||||
**Inventory and container workflows**
|
||||
|
||||
- `mcc_inventory_snapshot`: Read a snapshot of one inventory.
|
||||
- `mcc_inventory_search`: Search the player inventory and optionally open containers for matching items.
|
||||
- `mcc_inventories_list`: List open inventories and containers known to MCC.
|
||||
- `mcc_container_open_at`: Open a container block at world coordinates and wait for its inventory.
|
||||
- `mcc_container_close`: Close an open non-player container.
|
||||
- `mcc_inventory_window_action`: Perform a window action on an inventory slot.
|
||||
- `mcc_inventory_drop_item`: Drop an exact item count from an inventory.
|
||||
- `mcc_container_deposit_item`: Move items from the player inventory into an open container.
|
||||
- `mcc_container_withdraw_item`: Move items from an open container into the player inventory.
|
||||
|
||||
**Entities, signs, and dropped items**
|
||||
|
||||
- `mcc_entities_query`: Query tracked entities.
|
||||
- `mcc_entities_list`: List tracked entities with optional filters.
|
||||
- `mcc_entity_info`: Get detailed info for one tracked entity.
|
||||
- `mcc_entity_interact`: Interact with a tracked entity.
|
||||
- `mcc_entity_attack`: Attack a tracked entity.
|
||||
- `mcc_signs_find`: Find nearby signs by text.
|
||||
- `mcc_items_list`: List nearby dropped item entities.
|
||||
- `mcc_items_pickup`: Move to and pick up nearby dropped items of a given type.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Simple example usage</strong></summary>
|
||||
|
||||
Once your client is connected, keep the prompts simple and concrete.
|
||||
|
||||
- `What server is MCC connected to right now?`
|
||||
- `List online players and tell me whether Steve is nearby.`
|
||||
- `Preview a path to 10 80 0. If the path looks valid, move there.`
|
||||
- `Show my player inventory, then open the chest at 2 80 0 and tell me what is inside.`
|
||||
- `List nearby dropped Stone items and pick them up.`
|
||||
- `Find the nearest ArmorStand and show me its details.`
|
||||
|
||||
</details>
|
||||
|
||||
## Map
|
||||
|
||||
- **Description:**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue