Skip to main content

One post tagged with "ai"

View All Tags

Asking Your Scheduler Questions — The Milvaion MCP Server

· 10 min read
Ahmet Buğra Kösen
Software Developer

In the first post of this series I covered where Milvaion came from, and in the second how to monitor existing Hangfire/Quartz installations without changing them.

This post is about the newest addition to the system, and the one I argued with myself about the most: the MCP server.

Fair warning up front — this isn't an "AI solved everything" post. If anything, the part I spent the most time on while building this wasn't what it can do, but what it can't.


A Second Door to the Same Data

In the first post I said the real value isn't in running jobs, it's in seeing what happens after they run. The dashboard exists for exactly that, and it's the part I spent the most time on.

The MCP server doesn't replace it. The dashboard is still where you manage the system, build your workflows, edit a job, and watch logs stream live — and that's staying. What MCP adds is a second door to the same data: for the moments when you'd rather ask than look.

Here's where the difference shows up. The dashboard is very good at answering a question. A diagnosis, though, is usually not one question but a chain of them, each triggered by the last:

daily-invoice-export failed overnight. You read the exception on the occurrence screen — a timeout. Then you look back through history to ask "how long has this been happening?" Since Tuesday. Then you move to the worker screen for "was the worker even up on Tuesday?", then to the activity log for "did someone touch this job on Tuesday?"

Every one of those screens gives you the right answer. The part that strains is you: assembling the pieces you gathered from four screens in your head. By the fourth, you're trying to remember what you saw on the first.

Meanwhile the question in your head was one sentence the whole time: "daily-invoice-export has been failing since Tuesday, read the logs and tell me what changed."

The MCP server makes that sentence directly executable. It doesn't remove the navigating — it removes the cost of carrying context while you navigate.

Which door to use when is straightforward too: if you're going to change something, the dashboard. If you're trying to understand something, either works — whichever is faster in the moment.


Which Way Round Does This Work?

I'm giving this its own heading because most people get it backwards.

Your machine                          Your Milvaion server
┌────────────────────────┐ ┌──────────────────┐
│ Claude Code / Cursor / │ │ Milvaion API │
│ Copilot │ ──MCP──▶ │ /mcp │
│ │ │ │
│ • the model runs here │ ◀──JSON── │ • jobs │
│ • your subscription │ │ • logs │
│ • your tokens │ │ • metrics │
└────────────────────────┘ └──────────────────┘

Milvaion never calls a language model. No OpenAI, Anthropic, or Google key is stored on the server, no tokens are consumed, no outbound request is made.

Milvaion is purely the data source here. The model runs in your editor, on your subscription. The only credential involved is a Milvaion API key, which your editor uses to authenticate to Milvaion.

That distinction wasn't marketing detail for me, it was an architectural decision. If I had put an LLM integration inside Milvaion, I'd owe an enterprise team an answer to "which provider is this tool sending our job logs to." The current answer is simple: none of them. The data never leaves your network; you're the one making the model call, through an editor you already trust.


Setup

1. Create a read-only API key

Create an API key from the dashboard and grant only:

  • ScheduledJobManagement.List, ScheduledJobManagement.Detail
  • FailedOccurrenceManagement.List
  • WorkerManagement.List
  • WorkflowManagement.List

With this set, the assistant can investigate anything and change nothing. For most people, that's the right default.

2. Register the server in your editor

Milvaion is a remote HTTP MCP server, so any client supporting streamable HTTP transport can connect. In Claude Code you don't even need to edit a file:

claude mcp add --transport http milvaion \
https://milvaion.yourcompany.com/mcp \
--header "X-ApiKey: $MILVAION_API_KEY"

For Cursor, .cursor/mcp.json:

{
"mcpServers": {
"milvaion": {
"type": "http",
"url": "https://milvaion.yourcompany.com/mcp",
"headers": { "X-ApiKey": "your-api-key" }
}
}
}

There's an annoying reality here: every client wants the same thing under different key names. VS Code/Copilot expects the top-level key to be servers, not mcpServers. Windsurf says serverUrl, not url. Gemini CLI wants httpUrl.

This is the number one cause of "it connected but no tools show up." The number two cause is Copilot's chat not being switched to Agent mode — in Ask mode, MCP tools never appear at all.

The documentation has a full client matrix, along with the quirks specific to Claude Desktop and ChatGPT.

Don't put the key in the repository. Every client above supports either environment variable expansion or a runtime prompt. A config file with a live key in it gets committed eventually, and a key in git history means creating a new one and revoking the old.

3. Ask something

Which jobs failed last night?

daily-invoice-export has been failing since Tuesday. Read the logs and tell me what changed.

Is there a worker alive that can run SendReportJob?


What Does It Look Like in Practice?

When you ask the first question, the assistant doesn't call a single tool. The typical flow:

list_failures pulls the dead letter records → it sees multiple records for the same job and reaches for list_occurrences to look at history → it finds when the failures started → get_occurrence pulls that run's logs and exception detail → list_workers checks whether the worker was even alive at the time → list_activity_logs checks whether anyone touched the job around that date.

In other words, it builds the same chain you'd walk screen by screen in the dashboard, in the same order. The difference is that you don't have to hold the context.

Let me be clear about one thing: this isn't magical diagnosis. The assistant reads whatever is in the logs — the exact same data you see in the dashboard. But connecting "the first date I see this exception" to "what's in the activity log on that date" is something it does faster than a human at 9 a.m. who hasn't had their coffee.

And once you have the answer you usually head back to the dashboard anyway — because fixing the job, changing the cron, or re-triggering it happens there.


Tools and the Permission Model

There are 40+ tools, each gated by a permission. Roughly grouped:

GroupExamples
Readingget_overview, list_jobs, get_job, list_occurrences, get_occurrence, list_failures, list_workers, search_logs, summarize_logs, get_latest_report, list_activity_logs
Systemget_system_health, get_queue_stats, get_database_statistics, get_configuration
Runningtrigger_job, cancel_occurrence, set_job_active, trigger_workflow
Editingcreate_job, update_job, resolve_failures
Deletingdelete_job, delete_occurrences, delete_failures, delete_worker

The design decision here: you don't choose which tools are exposed, you choose what the key can do.

With a key granted only List and Detail, the assistant is left with fifteen reading tools and nothing else. When it calls a tool it lacks permission for, the error names the missing permission — so instead of blindly retrying, the model can tell you "you need to grant this."

One detail that's easy to miss but matters: get_occurrence returns the tail of the log rather than all of it, defaulting to the last 100 lines (raise it with logLines). A job that logs inside a loop could otherwise fill an assistant's entire context in a single call.


Prompts — More Important Than I Expected

Three prompt templates ship with the server:

PromptWhat it does
diagnose_jobWalks a failing job in order: failures, pattern, logs, worker health, then recent config changes
overnight_reviewReviews a given window and groups failures by cause rather than listing them one by one
explain_workflowDescribes a workflow's steps, branching, and data flow in plain language

At first glance these look like canned questions, but their function is different. Left to itself, a model tends to start with whichever tool it read first — it calls list_jobs and browses the job list, when the answer was in list_failures.

The prompts put a working order in front of the model. The difference is between the right answer, and the right answer three extra tool calls later.


What I Deliberately Left Out

I think this is the most important section of the post.

There are no tools at all for user management, roles, permissions, API keys, dispatcher control, or configuration management. No matter what you grant the key, none of it is reachable over MCP. The reason is simple: an assistant being able to mint a new API key or widen its own permissions is a scenario I don't even want to think about.

Workflow creation and editing are absent too. Building a directed graph with conditions and data mappings through tool calls is technically possible, but the right place for it is the visual builder. A workflow built on an assistant's guess of "I think you wanted to connect these two nodes" is not something anyone wants in production.

A few more security details:

  • Side effects are attributed. Anything triggered, cancelled, or marked resolved through MCP is logged with the key's name. The history preserves the answer to "was this a human or an assistant?"
  • Concurrency policies are never bypassed. trigger_job always dispatches with force disabled. Overriding a job's concurrency policy stays a deliberate human action in the dashboard.
  • Fields can't be cleared by accident. update_job only changes the arguments it's given; an omitted field is left alone rather than blanked.

And the part where I have to be honest:

The tool descriptions tell the model to "confirm before doing this" and to "prefer set_job_active over delete_job." But those are prompts, not guarantees. I can't promise you the model will follow them.

So my advice is unambiguous: grant Create, Update, and Delete only where you'd be comfortable giving a person the same access through the dashboard. Trigger causes real work to run. Delete is irreversible. In every other case, a read-only key is the right answer.


Verifying It Works

/mcp speaks JSON-RPC over HTTP, so you can test it with curl before involving a client at all:

curl -X POST http://localhost:5000/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-H "X-ApiKey: $MILVAION_API_KEY" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

Drop the header and the same request returns 401 — the quickest confirmation that the endpoint is protected.

The server runs in stateless mode; any API replica can serve any request, and no sticky sessions are needed behind a load balancer. The cost of that is that features requiring a persistent session — sampling, elicitation — aren't supported.


Conclusion

The question I asked myself while building the MCP server wasn't "how do I add AI to this." It was: how do I make a question that's already one sentence in my head askable in one sentence?

That the answer turned out to be MCP is partly a matter of timing — the protocol exists, and most teams already have Claude Code or Cursor open. All Milvaion had to do was expose its data to that ecosystem properly. Being a data source instead of writing my own LLM integration means less code and less responsibility.

The interesting part is this: most of the time I spent on this feature didn't go into adding tools, it went into deciding which ones not to add. Opening a natural language interface onto a scheduler is easy; keeping it safe in production is the actual work.

Milvaion is open source on GitHub under Apache 2.0. For the full tool list, client configurations, and security model of the MCP server, take a look at the documentation.

See you in the next one…