Skip to content

feat: expose monoscope as MCP server at /api/v1/mcp#367

Merged
tonyalaribe merged 12 commits into
masterfrom
feat/mcp-endpoint
May 3, 2026
Merged

feat: expose monoscope as MCP server at /api/v1/mcp#367
tonyalaribe merged 12 commits into
masterfrom
feat/mcp-endpoint

Conversation

@tonyalaribe

@tonyalaribe tonyalaribe commented May 2, 2026

Copy link
Copy Markdown
Contributor

Summary

  • New /api/v1/mcp JSON-RPC endpoint exposes every ApiV1Routes operation as an MCP tool — agents (Claude Code, Cursor, etc.) get typed tool schemas instead of one generic call.
  • The tool registry is built once from apiV1OpenApiSpec at startup; new endpoints become tools automatically with no MCP-side maintenance.
  • Tool calls forward as synthetic WAI requests into a re-served inner ApiV1Routes Application — no second auth pass, no handler duplication.

How it's wired

  • src/Web/MCP.hs (~250 LOC) — ToolEntry, mkToolsFromOpenApi, handleJsonRpc, runOperation, JSON-Schema helpers.
  • src/Web/Routes.hs — added mcp field to ApiV1Routes (re-uses the existing AuthProtect "api-key-auth"); widened server / apiV1Server to thread Logger / AuthContext / TracerProvider so the MCP handler can rebuild the inner sub-app.
  • src/System/Server.hsmkServer passes through the threaded args.
  • package.yaml — added insert-ordered-containers (already transitive via openapi3).

Supports initialize, tools/list, tools/call, and notifications/*. Tool names are derived from path+method (e.g. monoscope_schema_get, monoscope_monitors_monitor_id_get); the code prefers an explicit operationId if/when servant-openapi3 starts emitting them.

Test plan

  • Unit/integration: 5 new specs in test/integration/Web/ApiV1Spec.hs under describe "MCP" — registry shape, tools/list round-trip, initialize, tools/call monoscope_schema_get round-tripping into Schema.telemetrySchema, and unknown-tool isError-not-JSON-RPC-error semantics. All 5 pass locally.
  • Hosted smoke test once deployed:
    curl -s -H "Authorization: Bearer $KEY" -H 'Content-Type: application/json' \
         -X POST https://api.monoscope.tech/api/v1/mcp \
         -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
  • Wire into Claude Code via ~/.claude.json and confirm tools appear under /mcp.

tonyalaribe and others added 2 commits May 3, 2026 01:35
Adds a JSON-RPC endpoint that surfaces every ApiV1Routes operation as a
typed MCP tool. The registry is derived once from apiV1OpenApiSpec at
startup, so new endpoints become tools without touching MCP code; tool
calls are forwarded as synthetic WAI requests into a re-served inner
ApiV1Routes Application (no second auth pass, no handler duplication).

Tools cover initialize, tools/list, tools/call, and notifications/*.
Tool names are derived from path+method (e.g. monoscope_schema_get,
monoscope_monitors_monitor_id_get) since servant-openapi3 does not emit
operationIds for our spec; if operationIds are added later the registry
prefers them.

Tests cover registry shape, tools/list round-trip, initialize, tools/call
round-tripping monoscope_schema_get into Schema.telemetrySchema, and the
unknown-tool isError-not-JSON-RPC-error semantics.
tonyalaribe and others added 3 commits May 3, 2026 09:44
Replaces auto-derived monoscope_<resource>_<id>_<action>_<method> names
with the verb-first snake_case convention every peer MCP server uses
(Sentry, Grafana, Datadog, Honeycomb): list_monitors, get_monitor,
mute_monitor, search_events, apply_dashboard, whoami, etc.

Names come from a canonical (method, path) override map in MCP.hs;
unmapped routes fall back to the path-derived name. The MCP endpoint
itself is excluded so agents cannot recurse into it.

Drops the redundant "monoscope_" prefix — MCP clients already namespace
tools by server name.
Adds three Sentry/Grafana-style workflow tools alongside the OpenAPI-
derived REST tools:

- find_error_patterns — top established log patterns ranked by current
  hour count. Wraps getPatternsWithCurrentRates.
- search_events_nl — natural-language → KQL via runAgenticQuery; agent
  then calls search_events with the result.
- analyze_issue — fetches the issue and asks the LLM for a structured
  diagnosis (probable cause / signals / next steps).

Refactor: collapses ToolEntry + CompositeTool into one Tool record with
a Dispatch sum (ViaOpenApi | ViaComposite) — drops field-name prefixes,
single dispatcher path, single registry. allTools merges REST + composite
once at startup.

Derives ToJSON for LogPatternWithRate (snake_case via deriving-aeson) so
the composite tool returns it directly without a manual encoder.

Tests now cover the full dispatch chain: in-process registry/list/call
checks plus three e2e tests that POST through the real /api/v1/mcp HTTP
route with an API key and assert auth (401 without, 200 with) and tool
results (get_schema round-trips through to telemetrySchema).
@claude

claude Bot commented May 3, 2026

Copy link
Copy Markdown

Code Review — feat: MCP server at /api/v1/mcp

Good overall design: building the tool registry once from apiV1OpenApiSpec, forwarding via synthetic WAI requests, and keeping auth at the outer route layer are all solid choices. A few targeted notes focused on succinctness and leveraging existing packages/functions.


Reimplemented clamp (src/Web/MCP.hs:647)

clamp :: Ord a => a -> a -> a -> a
clamp lo hi = max lo . min hi

Data.Ord.clamp already exists and is imported in Routes.hs. The API differs (tuple argument), but it's the same concept. Replace the custom definition with:

import Data.Ord (clamp)
-- usage:
let lim = clamp (1, 200) (fromMaybe 20 (intArg "limit" args))

deref reimplements preview OA._Inline (src/Web/MCP.hs:318)

deref :: OA.Referenced a -> Maybe a
deref (OA.Inline a) = Just a
deref _ = Nothing

openapi3 exports _Inline :: Prism' (Referenced a) a, so this is just preview OA._Inline. Drop deref and write:

params = mapMaybe (preview OA._Inline) (op ^. OA.parameters)
mrb    = op ^. OA.requestBody >>= preview OA._Inline

Redundant nested list comprehension in mkInputSchema (src/Web/MCP.hs:330)

requireds = paramRequireds <> [w | w <- ["body" | bodyReq], isJust bodySchema]

The outer comprehension just filters a singleton — simplify to:

requireds = paramRequireds <> ["body" | bodyReq && isJust bodySchema]

bodyContentSchema — use listToMaybe (src/Web/MCP.hs:347)

bodyContentSchema rb = case IOH.elems (rb ^. OA.content) of
  (mto : _) -> AE.toJSON <$> (mto ^. OA.schema)
  [] -> Nothing

listToMaybe is already used elsewhere in the codebase:

bodyContentSchema rb =
  listToMaybe (IOH.elems (rb ^. OA.content)) >>= \mto -> AE.toJSON <$> (mto ^. OA.schema)

Or inline the lens further:

bodyContentSchema rb = rb ^? OA.content . to IOH.elems . _head >>= \mto -> AE.toJSON <$> (mto ^. OA.schema)

Verbosity in tools/list structure test (test/integration/Web/ApiV1Spec.hs:855)

let firstTool = resp ^? key "result" . key "tools" . _Array . traverse
case firstTool of
  Just _ -> do
    (resp ^? key "result" . key "tools" . _Array . traverse . key "name") `shouldSatisfy` isJust
    (resp ^? key "result" . key "tools" . _Array . traverse . key "description") `shouldSatisfy` isJust
    (resp ^? key "result" . key "tools" . _Array . traverse . key "inputSchema") `shouldSatisfy` isJust
  Nothing -> expectationFailure "expected at least one tool"

firstTool is just checking for presence, then the same path is traversed again with extra .key segments. The ^.. / _Array . _head pattern is cleaner and already used elsewhere in this same test file:

let tools = resp ^.. key "result" . key "tools" . _Array . traverse
tools `shouldSatisfy` (not . null)
let t = head tools
(t ^? key "name")        `shouldSatisfy` isJust
(t ^? key "description") `shouldSatisfy` isJust
(t ^? key "inputSchema") `shouldSatisfy` isJust

Minor: mkApiV1App rebuilds Application per call

buildApp pid (inside handleJsonRpc) reconstructs the WAI Application on every tools/call. This is cheap for Servant, but since pid is available at handler-build time you could partially apply once in apiV1Server and pass the already-bound (ProjectId -> Application) closure — which is what the code does. No change needed, just confirming the current approach is fine.


notifications/* returns AE.Null — confirm MCP spec compliance

The handler returns AE.Null (no JSON-RPC envelope) for all notifications. Per the MCP spec, notification responses should be omitted entirely (no response sent at all) rather than sending a null body. If the transport is HTTP+SSE this distinction matters; for the current request/response HTTP endpoint it should be fine, but worth a comment.


Summary

Issue File Priority
Reimplemented clamp Web/MCP.hs:647 Low
deref vs preview OA._Inline Web/MCP.hs:318 Low
Nested list comprehension Web/MCP.hs:330 Low
bodyContentSchema case → listToMaybe Web/MCP.hs:347 Low
Test verbosity ApiV1Spec.hs:855 Low

Core logic, auth threading, the NOINLINE CAF registry, and URL encoding in splitArgs all look correct. The test coverage (unit + e2e auth rejection + e2e round-trip) is solid.

- use Data.Ord.clamp (tuple-form) instead of custom helper
- use preview OA._Inline directly; drop deref helper
- simplify requireds: ["body" | bodyReq && isJust bodySchema]
- bodyContentSchema via listToMaybe instead of pattern match
- prefer Relude encodeUtf8/decodeUtf8 (drops Data.Text.Encoding +
  Data.ByteString.Lazy qualified imports)
- use Relude unlines/Down (drop Data.Ord.Down + T.unlines)
- collapse tools/list test into a single ^.. + for_ pass
- comment notifications/* response semantics for the HTTP transport
@claude

claude Bot commented May 3, 2026

Copy link
Copy Markdown

test

@claude

claude Bot commented May 3, 2026

Copy link
Copy Markdown
Monoscope Logo

Open-source observability platform with S3 storage

Ingest and explore logs, traces, and metrics stored in your S3 buckets. Query with natural language. Create AI agents that detect anomalies and send daily/weekly reports to your inbox.

GitHub Release
License
Haskell
Discord

WebsitePlaygroundDiscordTwitterDocumentation


Monoscope Dashboard

What is Monoscope?

Monoscope is an open-source observability platform that stores your telemetry data in S3-compatible storage. Self-host it or use our cloud offering.

Core capabilities:

  • 💰 S3 storage — Store years of logs, metrics, and traces affordably in your own S3 buckets
  • 💬 Natural language queries — Search your data using plain English via LLMs
  • 🤖 AI agents — Create agents that run on a schedule to detect anomalies and surface insights
  • 📧 Email reports — Receive daily/weekly summaries of important events and anomalies
  • 🔭 OpenTelemetry native — 750+ integrations out of the box
  • Live tail — Stream logs and traces in real-time
  • 🕵️ Unified view — Correlate logs, metrics, traces, and session replays in one place

Cloud vs Self-hosted

In both options, you bring your own S3 buckets—your data stays yours.

Cloud Self-hosted
Storage Your S3 buckets Your S3 buckets
Compute Managed by us You manage
Auth & SSO Built-in DIY
Alert channels Slack, PagerDuty, etc. Basic email
Pricing Usage-based Free (AGPL-3.0)

Start free on Cloud or continue below to self-host.


Quick Start

git clone https://github.com/monoscope-tech/monoscope.git
cd monoscope
docker-compose up

Visit http://localhost:8080 (default: admin/changeme)

Send Test Data

Populate your dashboard with test telemetry:

# Install telemetrygen
go install github.com/open-telemetry/opentelemetry-collector-contrib/cmd/telemetrygen@latest

# Send test traces (replace YOUR_API_KEY from the UI)
telemetrygen traces --otlp-endpoint localhost:4317 --otlp-insecure \
  --otlp-header 'Authorization="Bearer YOUR_API_KEY"' --traces 10

CLI

Manage your Monoscope project from the terminal — search logs, query metrics, manage monitors and dashboards, triage issues, and more.

curl -fsSL https://monoscope.tech/install.sh | bash
monoscope auth login

See the CLI reference for the full command list.

Agentic pipeline

Every CLI command emits a stable JSON envelope, so you can chain discovery →
search → triage without manual munging. This is the same pipeline a Claude
Code skill runs end-to-end:

# 1. What services exist? (Precomputed; no aggregation query needed.)
SVC=$(monoscope facets resource.service.name --top 1 \
        | jq -r '.["resource.service.name"][0].value')

# 2. Grab one error event from that service — id only, ready to chain.
ID=$(monoscope logs search 'severity.text=="error"' \
        --service "$SVC" --first --id-only)

# 3. Pull the surrounding 5 minutes of traffic, with a per-trace summary
#    showing which other services were affected.
monoscope events context --window 5m --summary \
  --at "$(monoscope events get "$ID" | jq -r .timestamp)" \
  | jq '.traces | sort_by(-.error_count) | .[0:3]'

# 4. Acknowledge the open issue once you have a hypothesis.
monoscope issues list --service "$SVC" --status open \
  | jq -r '.data[].id' | head -3 \
  | xargs -I {} monoscope issues ack {}

Each step's output shape is documented and stable:

Command Envelope
facets [FIELD] {<field_path>: [{value, count}, ...]}
events search (and logs/traces) {events: [...], count, has_more, cursor}
events context --summary {events, count, traces: [{trace_id, services, span_count, error_count}]}
issues list, monitors list, ... {data: [...], pagination: {has_more, total, cursor, page, per_page}}
auth status (agent mode) {authenticated, method, api_url, project}

Set MONOSCOPE_AGENT_MODE=1 (or run with --agent) to force JSON output and
disable interactive prompts — auto-detected when CI or CLAUDE_CODE is set.

Claude Code Skills

Let Claude investigate incidents, triage alerts, and write KQL queries using the monoscope CLI — install the skills plugin:

# Claude Code
claude plugin marketplace add monoscope-tech/skills
claude plugin install monoscope-skills@monoscope-skills

# or via npx (Cursor, Cline, Copilot, and other agents)
npx skills add monoscope-tech/skills

Restart Claude Code after installation. Skills activate automatically when relevant — e.g. "investigate the 500 errors in payment-api" or "do an on-call sweep".

See github.com/monoscope-tech/skills for the full skill list and documentation.


Integration

Auto-instrument your apps

Python
pip install opentelemetry-distro opentelemetry-exporter-otlp
opentelemetry-bootstrap -a install
OTEL_SERVICE_NAME="my-app" \
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317" \
opentelemetry-instrument python myapp.py
Node.js
npm install --save @opentelemetry/auto-instrumentations-node
OTEL_SERVICE_NAME="my-app" \
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317" \
node --require @opentelemetry/auto-instrumentations-node/register app.js
Java
curl -L https://github.com/open-telemetry/opentelemetry-java-instrumentation/releases/latest/download/opentelemetry-javaagent.jar -o otel-agent.jar
OTEL_SERVICE_NAME="my-app" \
OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4317" \
java -javaagent:otel-agent.jar -jar myapp.jar
Kubernetes
# Install OpenTelemetry Operator
kubectl apply -f https://github.com/open-telemetry/opentelemetry-operator/releases/latest/download/opentelemetry-operator.yaml

# Configure auto-instrumentation
kubectl apply -f - <<EOF
apiVersion: opentelemetry.io/v1alpha1
kind: Instrumentation
metadata:
  name: my-instrumentation
spec:
  exporter:
    endpoint: http://monoscope:4317
  propagators:
    - tracecontext
    - baggage
EOF

# Annotate your deployments for auto-instrumentation
kubectl patch deployment my-app -p \
  '{"spec":{"template":{"metadata":{"annotations":{"instrumentation.opentelemetry.io/inject-java":"my-instrumentation"}}}}}'

Natural Language Queries

Query your telemetry data in plain English:

  • "Show me all errors in the payment service in the last hour"
  • "What caused the spike in response time yesterday?"
  • "Which endpoints have the highest p99 latency?"

AI Agents & Reports

Create AI agents that monitor your systems on a schedule:

  • Scheduled analysis — Agents run at intervals you define (hourly, daily, weekly)
  • Anomaly detection — Automatically surface unusual patterns in logs, metrics, and traces
  • Email reports — Receive summaries of important events and insights directly in your inbox
  • Customizable focus — Configure agents to watch specific services, error types, or metrics

Architecture

graph LR
    A[Your Apps] -->|Logs/Metrics/Traces| B[Ingestion API]
    B --> C[TimeFusion Engine]
    C --> D[(S3 Storage)]
    D --> E[Query Engine]
    E --> F[Dashboards]
    D --> G[AI Agent Scheduler]
    G -->|LLM Analysis| H[Anomaly Detection]
    H --> I[Email Reports]
    H --> J[Alert Channels]
Loading

Powered by TimeFusion

Monoscope is built on TimeFusion, our open-source time-series database for observability workloads.

🗄️ S3-native Data lives in your S3 buckets—no vendor lock-in
🐘 PostgreSQL compatible Use any Postgres client or driver
500K+ events/sec Columnar storage with Apache Arrow
💵 Pay only for S3 No expensive proprietary storage fees

How It Compares

Feature Monoscope Datadog Elastic Prometheus
S3/Object Storage ✅ Native
Natural Language Query
AI Agents & Reports ✅ Built-in ❌ Add-on
Open Source ✅ AGPL-3.0
Self-hostable

Screenshots

Log Explorer - Unified View

Logs and trace spans displayed together in context for complete observability.

Log Explorer Main View

Trace Context Integration

See detailed trace information alongside logs for debugging complex distributed systems.

Log Explorer with Trace Context

Dashboard Analytics

Real-time metrics and performance monitoring with AI-powered insights.

Dashboard Analytics View

Trusted by Leading Companies

Andela Partna GrovePay SameDay
Platnova PayFonte ThePeer BlockRadar

"Monoscope notifies us about any slight change on the system. Features that would cost us a lot more elsewhere." — Samuel Joseph, Woodcore


Documentation


Roadmap

  • Custom dashboards builder
  • More out-of-the-box dashboards
  • AIOps workflow builder
  • Full migration to TimeFusion storage engine
  • Metrics aggregation rules
  • Multi-tenant workspace support
  • More alert channel integrations

See our public roadmap for details and to vote on features.


Community

💬 Discord • 🐛 Issues • 🐦 Twitter


License

AGPL-3.0. See LICENSE for details.

For commercial licensing options, contact us at [email protected].


Star on GitHub

@claude

claude Bot commented May 3, 2026

Copy link
Copy Markdown

Code Review

tonyalaribe and others added 2 commits May 3, 2026 10:35
The previous "apply review feedback" commit was supposed to land all of
these in MCP.hs but only the test-file edits made it through after the
rebase conflict resolution picked the wrong side. This puts them back:

- Data.Ord.clamp (tuple-form); drop custom helper
- preview OA._Inline; drop deref helper
- requireds collapses to ["body" | bodyReq && isJust bodySchema]
- bodyContentSchema via listToMaybe
- Relude encodeUtf8/decodeUtf8 throughout (drops Data.Text.Encoding +
  Data.ByteString.Lazy qualified imports)
- unlines/Down from Relude (drop Data.Ord.Down + T.unlines)
- comment notifications/* HTTP-vs-spec semantics
@claude

claude Bot commented May 3, 2026

Copy link
Copy Markdown

Code Review — PR #367: MCP server at /api/v1/mcp

The design is solid: deriving the tool registry from the OpenAPI spec avoids double-maintenance, the WAI sub-app forwarding is clean, and the composite tools add genuine agent ergonomics. A few concrete tightening opportunities below, mostly around using what is already in the project instead of re-deriving it.


src/Web/MCP.hs

clamp reimplemented (line 647)

Data.Ord.clamp already exists and is imported in Routes.hs, Anomalies.hs, and BackgroundJobs.hs. Its signature is (a,a) -> a -> a, so the call site becomes:

let lim = Data.Ord.clamp (1, 200) (fromMaybe 20 (intArg "limit" args))

Drop the three-line reimplementation and add Data.Ord (clamp) to the import.


deref — prefer the library prism (lines 318-320)

openapi3 exports _Inline :: Prism' (Referenced a) a, so deref collapses to deref = preview OA._Inline. Three lines to one, and uses the library directly instead of pattern-matching the internal constructor.


Confusing nested list comprehension for requireds (line 330)

requireds = paramRequireds <> [w | w <- ["body" | bodyReq], isJust bodySchema]

The nested guard-as-generator pattern is opaque. Equivalent and clear:

requireds = paramRequireds <> ["body" | bodyReq && isJust bodySchema]

deriveNameFromPath — two T.replace calls (line 299)

cleaned = T.replace "{" "" $ T.replace "}" "" p

One pass instead of two:

cleaned = T.filter (`notElem` "{}") p

renderJson — unnecessary ByteString hop (line 457)

aeson exports encodeToLazyText :: ToJSON a => a -> LText, avoiding the intermediate ByteString:

renderJson = TL.toStrict . AE.encodeToLazyText
-- import Data.Text.Lazy qualified as TL

textArg/intArglens-aeson is already in the project

The codebase uses lens-aeson (_String, _Number, key, etc.) throughout. The current helpers can be expressed as:

textArg k o = o ^? ix (AK.fromText k) . _String
intArg k o  = o ^? ix (AK.fromText k) . _Number . to round

Aligns with the rest of the codebase and drops the manual case dispatching.


bodyContentSchema — can use a lens traversal (lines 346-349)

With deref = preview OA._Inline:

bodyContentSchema rb =
  rb ^? OA.content . traverse . OA.schema . _Just . OA._Inline . to AE.toJSON

src/Web/Routes.hs

MCP self-exclusion by bare string (MCP.hs line 178)

(m, p) /= ("POST", "/mcp")

Fragile: if the route is ever prefixed or renamed, the MCP endpoint silently re-exposes itself as a tool. Extract a named constant or derive the exclusion from the route type so renames track automatically.


mkApiV1App rebuilt per tool call (Routes.hs lines 751-755)

A fresh Servant Application is constructed on every MCP tool invocation. For current call frequency this is fine, but since the value is pure (per pid) it could be memoised cheaply if MCP tools become hot.


Protocol version

"protocolVersion" AE..= ("2025-06-18" :: Text)

Verify this matches an actual published MCP spec release tag. The widely-documented stable release was 2025-03-26; an unrecognised version string will cause strict MCP clients to reject the initialize handshake.


Minor nits

  • Null body for body-less requests (callOpenApi): AE.encode AE.Null sends "null" as the body for GET endpoints. Servant ignores it, but a guard on b.method would make intent explicit.
  • deref discards $ref silently — fine now since servant-openapi3 inlines everything, but worth a comment as a known limitation.
  • Test helper locality: rpcSimple is defined in the same let block as rpcCall/rpcCallNamed but is only used in the e2e block; moving it near its uses would help readability.

Summary

The overall approach is sound and the test coverage is good (unit registry checks + e2e HTTP path). The main actionable items, in priority order:

  1. Drop the clamp reimplementation — use Data.Ord.clamp (1, 200).
  2. Replace deref with preview OA._Inline — uses the library prism directly.
  3. Simplify requireds, deriveNameFromPath, renderJson, textArg/intArg — small cuts that add up given the LOC budget focus.
  4. Verify protocol version 2025-06-18.

@claude

claude Bot commented May 3, 2026

Copy link
Copy Markdown

DELETE_ME_4365761583

@claude

claude Bot commented May 3, 2026

Copy link
Copy Markdown

Code Review

The architecture is clean: registry built once from apiV1OpenApiSpec, OpenAPI tools forwarded as synthetic WAI requests, composite workflow tools layered on top. A few things to tighten.

Succinctness

deriveNameFromPath - two T.replace calls can be one T.filter. The codebase already uses T.filter elsewhere (e.g. GitSync.hs). Replace: T.replace "{" "" dollar T.replace "}" "" p with T.filter (notElem "{}") p.

mkInputSchema - maybe id pattern is more idiomatic and shorter: maybe id (KM.insert "body") bodySchema paramProps instead of maybe paramProps (lambda b -> KM.insert "body" b paramProps) bodySchema.

Potential bugs

callOpenApi has no timeout on the inner WAI session. WT.runSession is synchronous - if the forwarded handler hangs (DB call, AI call), the MCP endpoint blocks indefinitely. The outer HTTP request timeout already fired before we entered the handler, so it will not save us. Wrap with System.Timeout.timeout.

bodyContentSchema picks the first media type arbitrarily via listToMaybe (IOH.elems ...). Not a current bug (all v1 routes are JSON-only) but worth a note so a future multi-content-type endpoint does not silently get the wrong schema.

allTools merge order: the comment says composite tools shadow OpenAPI tools but does not say why. Map (<>) is left-biased union, so composite-first is correct but subtle. A one-liner prevents a future reader from accidentally flipping the sides.

Security

analyzeIssue has a prompt injection surface. renderJson (AE.toJSON issue) is spliced directly into the LLM prompt. Issue message or stack-trace fields containing adversarial content can hijack the analysis. Wrapping the payload section with clear delimiters (triple-backtick fences, XML tags, etc.) reduces the attack surface.

searchEventsNL passes the timezone argument through unvalidated. It flows directly into AI.defaultAgenticConfig. An invalid value will not crash but will be silently misinterpreted by the LLM.

Minor

protocolVersion "2025-06-18" should be a named constant so it is easy to update at the next spec bump.

runTool only catches Servant.ServerError from ViaComposite handlers. DB and other effectful exceptions propagate out as HTTP 500s instead of isError: true tool results. The Dispatch constructor comment promises ServerErrors are caught - consider catching SomeException to honour that contract fully.

Test let mkTopApp and let mcpHttp are defined mid-describe after several it blocks. Moving them to the top let group alongside reg and dummyApp would be more consistent.

LogPatternWithRate JSON derivation matches the LogPattern pattern above it. Correct.

Overall: the WAI test session approach for inner dispatch is clever and avoids all handler duplication. Test coverage of key semantics (unknown tool returns isError not JSON-RPC error, auth rejection, round-trip through real HTTP route) is solid.

tonyalaribe and others added 2 commits May 3, 2026 10:48
Succinctness:
- deriveNameFromPath: one T.filter instead of two T.replace
- mkInputSchema: maybe id (KM.insert "body") bodySchema paramProps

Robustness:
- runTool: 30s System.Timeout cap so a hung sub-handler can't block the
  MCP endpoint; outer HTTP timeout fires too late to save us.
- runTool composite branch now also catches SomeException (was only
  ServerError) so DB / AI exceptions surface as isError tool results
  instead of HTTP 500s — matches the Dispatch comment's promise.

Security:
- analyzeIssue prompt fences the issue payload in <issue> tags and
  tells the model to treat its contents as data, reducing the
  prompt-injection surface from adversarial log/exception text.
- searchEventsNL now sanitizes the timezone arg (trim, length cap,
  alphanum + /_-+) before threading it into AgenticConfig.

Polish:
- protocolVersion lifted to mcpProtocolVersion constant.
- bodyContentSchema commented re first-media-type assumption.
- allTools merge order commented (Map (<>) is left-biased; composites
  win on collision).
- Test mkTopApp + mcpHttp helpers moved up alongside reg/dummyApp.
@claude

claude Bot commented May 3, 2026

Copy link
Copy Markdown

Code Review: feat: expose monoscope as MCP server at /api/v1/mcp

Overall a well-structured PR. The architecture (OpenAPI → tool registry, sub-app forwarding, composite tools) is clean and the test coverage is solid. A few issues worth addressing before merging:


Critical / Correctness

wai-extra / Network.Wai.Test in production library code
src/Web/MCP.hs imports Network.Wai.Test qualified as WT and uses WT.runSession, WT.SRequest, WT.srequest to forward tool calls into the inner app. wai-extra is added to test-suite deps only, but the library module uses it — so either this compiles because wai-extra was already a transitive library dep (fragile) or the cabal/package.yaml change is incomplete. Either way, using a test-oriented module in production library code is a smell that will confuse future maintainers. Consider a thin wrapper using Network.Wai.Internal directly, or accept wai-extra as an explicit library dep and document why.

Inner sub-app re-exposes the mcp endpoint
mkApiV1App serves the full ApiV1Routes — which includes mcp. The tool registry correctly filters it out:

, (m, p) /= ("POST", "/mcp") -- never expose MCP itself as a tool

but the sub-app itself still has that route live. If a composite tool ever forwards to /mcp directly, you get unbounded recursion with no depth guard. Worth either stripping the route from the inner app or adding an explicit guard in callOpenApi.

mkApiV1App rebuilds the routing tree on every tool call

mkApiV1App p =
  genericServeTWithContext
    (effToServantHandler env logger tp)
    (apiV1Server logger env tp p)
    Servant.EmptyContext

genericServeTWithContext builds a Servant routing tree each time buildApp pid is evaluated. This is called inside runTool for every MCP tool call. Cache at least the pid → Application mapping (or build the app once per request and pass it through).


Minor Issues

No response size cap in callOpenApi
Large API responses (e.g., search_events returning thousands of events) are stringified wholesale into the MCP content field. Consider truncating bodyTxt at a reasonable limit (e.g., 64 KB) before sending to the agent, with an indication that the response was truncated.

intArg uses round for integer parameters

intArg k o = KM.lookup (AK.fromText k) o >>= \case AE.Number n -> Just (round n); _ -> Nothing

If an agent passes 4.9 as a limit, it rounds to 5. floor / truncate is more conventional for limit/count args, and round could silently accept floats where only integers are valid. Consider adding a bounds check or using toBoundedInteger from the scientific package (already transitive via aeson).

apiV1Server signature now threads Logger → AuthContext → TracerProvider solely for MCP
These were only needed because mkApiV1App must reconstruct a full Application. If the effectful environment already carries them (as Reader effects in ATBaseCtx), they could be read from context inside the MCP handler rather than threaded through the entire apiV1Server signature. This avoids polluting every call site.


Conciseness / Style

allTools — use Map.fromListWith const or a keying function instead of list comprehension + Map.fromList:

-- current
Map.fromList [(t.name, t) | t <- compositeTools] <> mkToolsFromOpenApi spec

-- slightly tighter with OverloadedRecordDot already in scope
Map.fromList (map (\t -> (t.name, t)) compositeTools) <> mkToolsFromOpenApi spec
-- or
foldMap' (\t -> Map.singleton t.name t) compositeTools <> mkToolsFromOpenApi spec

objSchema key conversion

"properties" AE..= AE.object [(AK.fromText k, v) | (k, v) <- props]

Can use first AK.fromText <$> props (or change the function signature to [(Key, AE.Value)] since all call sites use string literals — then OverloadedStrings handles it automatically).

bodyContentSchema picks listToMaybe (IOH.elems ...) but a more explicit preference for application/json would be safer long-term (noted in the comment, just worth doing now while the code is fresh).

Unnecessary explicit AE.Bool False in okResult:

"isError" AE..= False  -- Bool is inferred, no need for AE.Bool False cast in toolError either

Minor, but consistent with how True is written in toolError.


Test Coverage

Good coverage overall. Missing:

  • notifications/* handling (returns AE.Null per spec — worth a unit test to lock in the behaviour)
  • deriveNameFromPath fallback naming (no override → path-derived name)
  • POST endpoint path with body forwarding (current tests only exercise get_schema which has no request body)
  • splitArgs with multi-value / array query params (current implementation takes only the last match; explicitly test or document the limitation)

Nits

  • mcpProtocolVersion = "2025-06-18" — this is a future date (today is 2026-05-03); if this is the published MCP spec version string that's fine, but worth a comment so it doesn't look like a stale timestamp.
  • The {-# NOINLINE mcpToolRegistry #-} pragma is correct and intentional — a one-liner comment explaining why (NOINLINE ensures the CAF is computed once and shared) would prevent future reviewers from removing it.

tonyalaribe and others added 2 commits May 3, 2026 11:03
Correctness:
- callOpenApi rejects path == "/mcp" defensively. The registry already
  filters it, so this only fires if someone adds an OpenApiBinding
  pointing at the MCP route — fail fast instead of recursing.
- Build the inner Servant Application once per tools/call (in
  handleJsonRpc) instead of on every ViaOpenApi dispatch inside runTool.
  runTool now takes the prebuilt Application directly.

Robustness:
- Cap REST tool response bodies at 64 KB before stuffing them into the
  MCP `content` field; structuredContent retains the full untruncated
  JSON for typed clients.
- intArg uses `floor` (was `round`) — `limit: 4.9` should not silently
  become 5.

Schema accuracy:
- bodyContentSchema prefers application/json before falling back to
  whatever's first; matches how MCP forwards request bodies.

Documentation:
- Comment justifying Network.Wai.Test usage from a library module
  (wai-extra is already an explicit lib dep, this is the cheapest way
  to drive an Application synchronously).

Tests:
- notifications/* returns AE.Null with no envelope.
- tools/call create_team exercises POST body forwarding + round-trip.
@claude

claude Bot commented May 3, 2026

Copy link
Copy Markdown

Code Review — MCP Server (#367)

Well-structured addition overall. The registry-at-startup + WAI forwarding architecture is clean, the composite tools pattern is useful, and test coverage is solid. Some issues worth addressing:


Bug / Correctness

decodeUtf8 bodyLBS before truncation (callOpenApi, ~L528)
decodeUtf8 bodyLBS decodes the entire (potentially multi-MB) lazy bytestring to Text before truncateText trims it. This defeats the truncation cap for large search_events payloads. Truncate the bytes first:

let bodyBs   = LBS.toStrict (LBS.take (fromIntegral maxToolBodyBytes + 1) bodyLBS)
    bodyTxt  = decodeUtf8 bodyBs <> if LBS.length bodyLBS > fromIntegral maxToolBodyBytes then "\n…[truncated, full payload in structuredContent]" else ""

truncateText — "byte cap" vs character cap
The comment says "byte cap" but T.length counts Unicode code points. For UTF-8 payloads with multi-byte characters the actual byte budget will be larger than maxToolBodyBytes. Minor but the comment should say "character cap", or switch to a byte-based cut.

intArg uses floor (L696)

intArg k o = KM.lookup (AK.fromText k) o >>= \case AE.Number n -> Just (floor n); _ -> Nothing

truncate is the right conversion for JSON numbers intended as integers (matches JS Math.trunc semantics). floor is incorrect for negative inputs like -1.9-2.


Performance

mkApiV1App pid rebuilds the Servant routing tree on every tools/call
genericServeTWithContext walks the ApiV1Routes record via GHC generics at runtime (~60 routes). The comment says it was moved from "per ViaOpenApi dispatch" to "per tools/call", which is an improvement, but building it once per pid (e.g. via a simple IORef (HashMap ProjectId Application)) would eliminate the repeated work entirely. Since pid is known at auth time, the app could be built there and threaded in.


Conciseness / Packages

deriveNameFromPath — notElem on String

-- current
T.filter (`notElem` ("{}" :: String)) p
-- clearer and avoids the String coercion
T.filter (\c -> c /= '{' && c /= '}') p

pathItemOps — enumerate via table
The 8-method catMaybes list can be shortened using a (method, lens) table (reduces vertical space by ~half):

pathItemOps p = catMaybes
  [ (m,) <$> p ^. l
  | (m, l) <-
      [ ("GET", OA.get), ("POST", OA.post), ("PUT", OA.put), ("PATCH", OA.patch)
      , ("DELETE", OA.delete), ("HEAD", OA.head_), ("OPTIONS", OA.options), ("TRACE", OA.trace)
      ]
  ]

toolsListJson — extra where clause
The descriptor helper is only called once; an inline list comprehension removes the indirection:

toolsListJson reg =
  AE.object
    [ "tools" AE..=
        [ AE.object ["name" AE..= t.name, "description" AE..= t.description, "inputSchema" AE..= t.inputSchema]
        | t <- Map.elems reg
        ]
    ]

Security

analyzeIssue — prompt injection fencing is weak
<issue> XML tags are a well-known incomplete mitigation; adversarial content in log messages or stack traces can still escape. The comment acknowledges this, which is good. If AI.callOpenAIAPIEff (or a sibling) accepts separate system/user message lists, use them — put the instructions in system and the issue JSON in user. That's the correct mitigation level for untrusted data.


Minor

  • mcpProtocolVersion = "2025-06-18" — verify this is the spec revision you're targeting; the current stable MCP release is 2025-03-26. Using a future-dated draft version string may cause clients to reject the handshake.
  • dummyApp _ = error "dummyApp invoked unexpectedly" in tests — fine for now, but throwIO (or returning a 500 Response) avoids a partial function in test helpers.
  • bodyContentSchema uses show mt to check for "application/json" prefix. This works but relies on the Show instance of MediaType producing the MIME string. Data.OpenApi.MediaType has a Text-based representation — using it directly would be more robust.

@tonyalaribe
tonyalaribe merged commit 9fe0708 into master May 3, 2026
3 checks passed
@tonyalaribe
tonyalaribe deleted the feat/mcp-endpoint branch May 3, 2026 09:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant