@aryasaatvik/motel 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/AGENTS.md +180 -0
  2. package/LICENSE +21 -0
  3. package/README.md +137 -0
  4. package/package.json +119 -0
  5. package/skills/motel-debug/SKILL.md +203 -0
  6. package/skills/motel-debug/references/effect.md +38 -0
  7. package/src/App.tsx +414 -0
  8. package/src/StartupGate.test.ts +80 -0
  9. package/src/StartupGate.tsx +263 -0
  10. package/src/cli.ts +205 -0
  11. package/src/config.ts +45 -0
  12. package/src/daemon.test.ts +547 -0
  13. package/src/daemon.ts +486 -0
  14. package/src/domain.test.ts +62 -0
  15. package/src/domain.ts +291 -0
  16. package/src/httpApi.ts +391 -0
  17. package/src/httpListPolicy.test.ts +76 -0
  18. package/src/httpListPolicy.ts +129 -0
  19. package/src/index.tsx +25 -0
  20. package/src/instructions.ts +72 -0
  21. package/src/launchAgent.test.ts +430 -0
  22. package/src/launchAgent.ts +462 -0
  23. package/src/localServer.ts +568 -0
  24. package/src/locator.ts +138 -0
  25. package/src/mcp.ts +433 -0
  26. package/src/motel.test.ts +176 -0
  27. package/src/motel.ts +88 -0
  28. package/src/motelClient.ts +353 -0
  29. package/src/opentui-jsx.d.ts +11 -0
  30. package/src/otlp.test.ts +65 -0
  31. package/src/otlp.ts +162 -0
  32. package/src/otlpProtobuf.ts +35 -0
  33. package/src/queryFilters.ts +39 -0
  34. package/src/registry.ts +115 -0
  35. package/src/runtime.ts +40 -0
  36. package/src/server.ts +11 -0
  37. package/src/services/AsyncIngest.ts +82 -0
  38. package/src/services/LogQueryService.ts +29 -0
  39. package/src/services/TelemetryQuery.ts +62 -0
  40. package/src/services/TelemetryStore.ts +2554 -0
  41. package/src/services/TraceQueryService.ts +43 -0
  42. package/src/services/ingestRpc.ts +39 -0
  43. package/src/services/queryRpc.ts +15 -0
  44. package/src/services/telemetryQueryWorker.ts +32 -0
  45. package/src/services/telemetryWorker.ts +59 -0
  46. package/src/startupBench.ts +19 -0
  47. package/src/startupLifecycle.ts +57 -0
  48. package/src/storybook/aiChatStory.tsx +244 -0
  49. package/src/storybook/fixtures/errorState.ts +44 -0
  50. package/src/storybook/fixtures/imagePaste.ts +34 -0
  51. package/src/storybook/fixtures/index.ts +62 -0
  52. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  53. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  54. package/src/storybook/fixtures/short.ts +27 -0
  55. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  56. package/src/telemetry.test.ts +1053 -0
  57. package/src/ui/AiChatView.tsx +308 -0
  58. package/src/ui/AttrFilterModal.tsx +120 -0
  59. package/src/ui/ServiceLogs.tsx +115 -0
  60. package/src/ui/SpanContentView.tsx +184 -0
  61. package/src/ui/SpanDetail.tsx +215 -0
  62. package/src/ui/SpanDetailFull.tsx +224 -0
  63. package/src/ui/SpanDetailPane.tsx +90 -0
  64. package/src/ui/TraceDetailsPane.tsx +175 -0
  65. package/src/ui/TraceList.tsx +254 -0
  66. package/src/ui/Waterfall.tsx +389 -0
  67. package/src/ui/aiChatModel.test.ts +391 -0
  68. package/src/ui/aiChatModel.ts +773 -0
  69. package/src/ui/aiState.ts +59 -0
  70. package/src/ui/app/TraceListPane.tsx +39 -0
  71. package/src/ui/app/TraceWorkspace.tsx +452 -0
  72. package/src/ui/app/useAppLayout.ts +101 -0
  73. package/src/ui/app/useTraceScreenData.ts +590 -0
  74. package/src/ui/atoms.ts +131 -0
  75. package/src/ui/cachedLoader.test.ts +23 -0
  76. package/src/ui/cachedLoader.ts +60 -0
  77. package/src/ui/filterParser.test.ts +56 -0
  78. package/src/ui/filterParser.ts +45 -0
  79. package/src/ui/format.ts +128 -0
  80. package/src/ui/loaders.ts +101 -0
  81. package/src/ui/persistence.ts +41 -0
  82. package/src/ui/primitives.tsx +198 -0
  83. package/src/ui/state.ts +6 -0
  84. package/src/ui/theme.ts +160 -0
  85. package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
  86. package/src/ui/traceSortNav.repro.seed.ts +62 -0
  87. package/src/ui/traceSortNav.repro.test.ts +231 -0
  88. package/src/ui/useAttrFilterPicker.ts +89 -0
  89. package/src/ui/useKeyboardNav.ts +1023 -0
  90. package/src/ui/waterfallFilter.test.ts +84 -0
  91. package/src/ui/waterfallFilter.ts +59 -0
  92. package/src/ui/waterfallModel.ts +130 -0
  93. package/src/ui/waterfallNav.repro.seed.ts +86 -0
  94. package/src/ui/waterfallNav.repro.test.ts +271 -0
  95. package/src/ui/waterfallNav.test.ts +453 -0
  96. package/src/ui/waterfallNav.ts +75 -0
  97. package/web/dist/assets/index-B01z9BaO.css +2 -0
  98. package/web/dist/assets/index-CYlBaY9O.js +22 -0
  99. package/web/dist/index.html +13 -0
package/src/locator.ts ADDED
@@ -0,0 +1,138 @@
1
+ import { Effect, Layer, Ref, Context } from "effect"
2
+ import { listAliveEntries, MOTEL_SERVICE_ID, type RegistryEntry } from "./registry.js"
3
+
4
+ export class LocatorError extends Error {
5
+ readonly _tag = "LocatorError"
6
+ constructor(readonly detail: string) {
7
+ super(detail)
8
+ }
9
+ }
10
+
11
+ const ambiguousDetail = (candidates: readonly RegistryEntry[]) =>
12
+ `Multiple motel instances running and none match cwd. Set MOTEL_URL to choose one:\n` +
13
+ candidates.map((c) => ` - ${c.url} (workdir=${c.workdir}, pid=${c.pid})`).join("\n")
14
+
15
+ type Resolved = {
16
+ readonly url: string
17
+ readonly pid: number
18
+ readonly workdir: string
19
+ readonly version: string
20
+ readonly cwdMatch: boolean
21
+ readonly instanceCount: number
22
+ readonly source: "env" | "registry"
23
+ }
24
+
25
+ type HealthShape = {
26
+ readonly ok: boolean
27
+ readonly service: string
28
+ readonly pid: number
29
+ readonly url: string
30
+ readonly workdir: string
31
+ readonly version: string
32
+ }
33
+
34
+ const handshake = (url: string): Effect.Effect<HealthShape, LocatorError> =>
35
+ Effect.tryPromise({
36
+ try: async () => {
37
+ const res = await fetch(new URL("/api/health", url), {
38
+ signal: AbortSignal.timeout(1500),
39
+ })
40
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
41
+ const body = (await res.json()) as HealthShape
42
+ if (body.service !== MOTEL_SERVICE_ID) {
43
+ throw new Error(`service=${body.service} (expected ${MOTEL_SERVICE_ID})`)
44
+ }
45
+ return body
46
+ },
47
+ catch: (err) => new LocatorError(`Handshake with ${url} failed: ${(err as Error).message}`),
48
+ })
49
+
50
+ const pickByCwd = (entries: readonly RegistryEntry[], cwd: string) => {
51
+ const withSep = cwd.endsWith("/") ? cwd : cwd + "/"
52
+ const matching = entries
53
+ .filter((e) => {
54
+ const workdir = e.workdir.endsWith("/") ? e.workdir : e.workdir + "/"
55
+ return withSep === workdir || withSep.startsWith(workdir)
56
+ })
57
+ .sort((a, b) => b.workdir.length - a.workdir.length)
58
+ return matching[0] ?? null
59
+ }
60
+
61
+ const discover = Effect.fn("Locator.discover")(function* () {
62
+ const envUrl = process.env.MOTEL_URL?.trim()
63
+ if (envUrl) {
64
+ const health = yield* handshake(envUrl)
65
+ return {
66
+ url: envUrl,
67
+ pid: health.pid,
68
+ workdir: health.workdir,
69
+ version: health.version,
70
+ cwdMatch: process.cwd().startsWith(health.workdir),
71
+ instanceCount: 1,
72
+ source: "env" as const,
73
+ }
74
+ }
75
+
76
+ const all = listAliveEntries()
77
+
78
+ if (all.length === 0) {
79
+ return yield* Effect.fail(
80
+ new LocatorError(
81
+ "No motel instance found. Start one with `bun run server` from your project root, then retry.",
82
+ ),
83
+ )
84
+ }
85
+
86
+ const cwd = process.cwd()
87
+ const byCwd = pickByCwd(all, cwd)
88
+ const chosen = byCwd ?? (all.length === 1 ? all[0]! : null)
89
+
90
+ if (!chosen) {
91
+ return yield* Effect.fail(new LocatorError(ambiguousDetail(all)))
92
+ }
93
+
94
+ const health = yield* handshake(chosen.url)
95
+
96
+ if (health.pid !== chosen.pid) {
97
+ return yield* Effect.fail(
98
+ new LocatorError(
99
+ `Registry entry pid=${chosen.pid} but server at ${chosen.url} reports pid=${health.pid}. Stale entry — next discovery will prune it.`,
100
+ ),
101
+ )
102
+ }
103
+
104
+ return {
105
+ url: chosen.url,
106
+ pid: health.pid,
107
+ workdir: health.workdir,
108
+ version: health.version,
109
+ cwdMatch: cwd.startsWith(chosen.workdir),
110
+ instanceCount: all.length,
111
+ source: "registry" as const,
112
+ }
113
+ })
114
+
115
+ export class Locator extends Context.Service<
116
+ Locator,
117
+ {
118
+ readonly resolve: Effect.Effect<Resolved, LocatorError>
119
+ readonly invalidate: Effect.Effect<void>
120
+ }
121
+ >()("motel/Locator") {}
122
+
123
+ export const LocatorLive = Layer.effect(
124
+ Locator,
125
+ Effect.gen(function* () {
126
+ const cache = yield* Ref.make<Resolved | null>(null)
127
+ return {
128
+ resolve: Effect.gen(function* () {
129
+ const cached = yield* Ref.get(cache)
130
+ if (cached) return cached
131
+ const resolved = yield* discover()
132
+ yield* Ref.set(cache, resolved)
133
+ return resolved
134
+ }),
135
+ invalidate: Ref.set(cache, null),
136
+ }
137
+ }),
138
+ )
package/src/mcp.ts ADDED
@@ -0,0 +1,433 @@
1
+ #!/usr/bin/env bun
2
+ import { BunRuntime, BunStdio } from "@effect/platform-bun"
3
+ import { Effect, Layer, Logger, Schema } from "effect"
4
+ import { MOTEL_VERSION } from "./registry.js"
5
+ import { McpServer, Tool, Toolkit } from "effect/unstable/ai"
6
+ import { TraceSpanStatus } from "./domain.js"
7
+ import { MotelClient, MotelClientLive } from "./motelClient.js"
8
+ import { Locator, LocatorLive } from "./locator.js"
9
+
10
+ const Attributes = Schema.optional(
11
+ Schema.Record(Schema.String, Schema.String).annotate({
12
+ description:
13
+ "Arbitrary OTel attribute filters. Key is the attribute name WITHOUT the 'attr.' prefix (it is added for you). Values must be strings.",
14
+ }),
15
+ )
16
+
17
+ const AttributeContains = Schema.optional(
18
+ Schema.Record(Schema.String, Schema.String).annotate({
19
+ description:
20
+ "Case-insensitive substring attribute filters. Key is the attribute name WITHOUT the 'attrContains.' prefix (it is added for you). Values must be strings.",
21
+ }),
22
+ )
23
+
24
+ const Lookback = Schema.optional(
25
+ Schema.String.annotate({
26
+ description:
27
+ "Time window to look back, e.g. '15m', '1h', '6h', '1d'. Max 24h. Default 60m.",
28
+ }),
29
+ )
30
+
31
+ const Limit = Schema.optional(
32
+ Schema.Number.annotate({ description: "Max items to return in this page. Tool defaults apply." }),
33
+ )
34
+
35
+ const Cursor = Schema.optional(
36
+ Schema.String.annotate({
37
+ description:
38
+ "Opaque pagination cursor from a previous response's meta.nextCursor. Pass it back to fetch the next page.",
39
+ }),
40
+ )
41
+
42
+ const ServiceParam = Schema.optional(
43
+ Schema.String.annotate({ description: "Filter by OTel service name (e.g. 'opencode', 'my-app')." }),
44
+ )
45
+
46
+ const Status = Schema.optional(
47
+ TraceSpanStatus.annotate({
48
+ description:
49
+ "Filter by trace health. 'error' = at least one span errored. 'ok' = no errors.",
50
+ }),
51
+ )
52
+
53
+ const Severity = Schema.optional(
54
+ Schema.String.annotate({
55
+ description: "Filter by log severity, e.g. TRACE, DEBUG, INFO, WARN, ERROR, FATAL.",
56
+ }),
57
+ )
58
+
59
+ const StatusTool = Tool.make("motel_status", {
60
+ description:
61
+ "Check which motel instance this shim is connected to. Call this FIRST if any other tool errors, to confirm the connection. Returns url, version, workdir, whether the cwd matches, and how many motel instances are running on this machine.",
62
+ parameters: Tool.EmptyParams,
63
+ success: Schema.Struct({
64
+ connected: Schema.Boolean,
65
+ url: Schema.optional(Schema.String),
66
+ version: Schema.optional(Schema.String),
67
+ workdir: Schema.optional(Schema.String),
68
+ cwdMatch: Schema.optional(Schema.Boolean),
69
+ instanceCount: Schema.optional(Schema.Number),
70
+ source: Schema.optional(Schema.String),
71
+ error: Schema.optional(Schema.String),
72
+ }),
73
+ }).annotate(Tool.Readonly, true)
74
+
75
+ const ServicesTool = Tool.make("motel_services", {
76
+ description:
77
+ "List every OTel service name that has emitted traces or logs recently. Use this to discover what's being observed before narrowing down with motel_search_traces or motel_search_logs.",
78
+ parameters: Tool.EmptyParams,
79
+ success: Schema.Unknown,
80
+ }).annotate(Tool.Readonly, true)
81
+
82
+ const FacetsTool = Tool.make("motel_facets", {
83
+ description:
84
+ "Return distinct values and counts for a given field, so the agent can see what data exists before filtering. For traces, valid fields include 'service', 'operation', 'status'. For logs, 'service', 'severity', 'scope'. Supports attr.<key> fields too.",
85
+ parameters: Schema.Struct({
86
+ type: Schema.Literals(["traces", "logs"]).annotate({
87
+ description: "Which dataset to facet.",
88
+ }),
89
+ field: Schema.String.annotate({
90
+ description: "The column or attr.<key> to return distinct values for.",
91
+ }),
92
+ service: ServiceParam,
93
+ lookback: Lookback,
94
+ limit: Limit,
95
+ }),
96
+ success: Schema.Unknown,
97
+ }).annotate(Tool.Readonly, true)
98
+
99
+ const SearchTracesTool = Tool.make("motel_search_traces", {
100
+ description:
101
+ "Search distributed traces by service, operation, error status, minimum duration, time window, and arbitrary OTel attributes. Returns compact trace summaries with traceId, duration, error count, span count, and a nextCursor. Drill into a specific trace with motel_get_trace. For 'what just broke' investigations, pass status='error' with a short lookback like '15m'.",
102
+ parameters: Schema.Struct({
103
+ service: ServiceParam,
104
+ operation: Schema.optional(
105
+ Schema.String.annotate({ description: "Substring match on span operation name." }),
106
+ ),
107
+ status: Status,
108
+ minDurationMs: Schema.optional(
109
+ Schema.Number.annotate({ description: "Only return traces slower than this (ms)." }),
110
+ ),
111
+ attributes: Attributes,
112
+ lookback: Lookback,
113
+ limit: Limit,
114
+ cursor: Cursor,
115
+ }),
116
+ success: Schema.Unknown,
117
+ }).annotate(Tool.Readonly, true)
118
+
119
+ const GetTraceTool = Tool.make("motel_get_trace", {
120
+ description:
121
+ "Fetch a single trace by its 32-character hex traceId, including the full span tree ordered parent-first. Use this to drill into a trace found via motel_search_traces. For the logs emitted inside this trace, use motel_get_trace_logs instead.",
122
+ parameters: Schema.Struct({
123
+ traceId: Schema.String.annotate({ description: "Full 32-character hex trace ID." }),
124
+ }),
125
+ success: Schema.Unknown,
126
+ }).annotate(Tool.Readonly, true)
127
+
128
+ const GetTraceLogsTool = Tool.make("motel_get_trace_logs", {
129
+ description:
130
+ "Fetch log records correlated with a specific trace, across all spans. When investigating a failing trace, call this before motel_search_logs — it is the most scoped and usually the most informative log view.",
131
+ parameters: Schema.Struct({
132
+ traceId: Schema.String.annotate({ description: "Full 32-character hex trace ID." }),
133
+ lookback: Lookback,
134
+ limit: Limit,
135
+ cursor: Cursor,
136
+ }),
137
+ success: Schema.Unknown,
138
+ }).annotate(Tool.Readonly, true)
139
+
140
+ const GetTraceSpansTool = Tool.make("motel_get_trace_spans", {
141
+ description:
142
+ "Fetch the flat span list for a specific trace. Use this when you already know the traceId and want to inspect span durations, status, parents, and raw attributes without the nested trace wrapper.",
143
+ parameters: Schema.Struct({
144
+ traceId: Schema.String.annotate({ description: "Full 32-character hex trace ID." }),
145
+ }),
146
+ success: Schema.Unknown,
147
+ }).annotate(Tool.Readonly, true)
148
+
149
+ const SearchSpansTool = Tool.make("motel_search_spans", {
150
+ description:
151
+ "Search spans directly by service, traceId, operation, parentOperation, status, time window, and raw OTel attributes. Use this when traces are too coarse and you need to find the exact span or suspicious operation first.",
152
+ parameters: Schema.Struct({
153
+ service: ServiceParam,
154
+ traceId: Schema.optional(
155
+ Schema.String.annotate({ description: "Scope search to a single trace ID." }),
156
+ ),
157
+ operation: Schema.optional(
158
+ Schema.String.annotate({ description: "Substring match on span operation name." }),
159
+ ),
160
+ parentOperation: Schema.optional(
161
+ Schema.String.annotate({ description: "Substring match on parent operation name." }),
162
+ ),
163
+ status: Status,
164
+ attributes: Attributes,
165
+ attributeContains: AttributeContains,
166
+ lookback: Lookback,
167
+ limit: Limit,
168
+ }),
169
+ success: Schema.Unknown,
170
+ }).annotate(Tool.Readonly, true)
171
+
172
+ const GetSpanTool = Tool.make("motel_get_span", {
173
+ description:
174
+ "Fetch a single span by its 16-character hex spanId. Use this after motel_search_spans to inspect one span's full payload, parent trace, raw tags, and events.",
175
+ parameters: Schema.Struct({
176
+ spanId: Schema.String.annotate({ description: "Full 16-character hex span ID." }),
177
+ }),
178
+ success: Schema.Unknown,
179
+ }).annotate(Tool.Readonly, true)
180
+
181
+ const GetSpanLogsTool = Tool.make("motel_get_span_logs", {
182
+ description:
183
+ "Fetch log records correlated with a specific span. Use this after motel_get_span when you need the exact logs emitted from that one span, not the entire trace.",
184
+ parameters: Schema.Struct({
185
+ spanId: Schema.String.annotate({ description: "Full 16-character hex span ID." }),
186
+ lookback: Lookback,
187
+ limit: Limit,
188
+ cursor: Cursor,
189
+ }),
190
+ success: Schema.Unknown,
191
+ }).annotate(Tool.Readonly, true)
192
+
193
+ const SearchLogsTool = Tool.make("motel_search_logs", {
194
+ description:
195
+ "Search logs by service, trace/span correlation, body substring, time window, and arbitrary OTel attributes. Returns log entries with a nextCursor. For logs tied to a known traceId, prefer motel_get_trace_logs — it is more focused.",
196
+ parameters: Schema.Struct({
197
+ service: ServiceParam,
198
+ severity: Severity,
199
+ traceId: Schema.optional(
200
+ Schema.String.annotate({ description: "Filter by trace ID." }),
201
+ ),
202
+ spanId: Schema.optional(
203
+ Schema.String.annotate({ description: "Filter by span ID." }),
204
+ ),
205
+ body: Schema.optional(
206
+ Schema.String.annotate({ description: "Substring match on log body (case-sensitive)." }),
207
+ ),
208
+ attributes: Attributes,
209
+ attributeContains: AttributeContains,
210
+ lookback: Lookback,
211
+ limit: Limit,
212
+ cursor: Cursor,
213
+ }),
214
+ success: Schema.Unknown,
215
+ }).annotate(Tool.Readonly, true)
216
+
217
+ const SearchAiCallsTool = Tool.make("motel_search_ai_calls", {
218
+ description:
219
+ "Search normalized AI calls such as streamText and generateText by session, provider, model, functionId, operation, duration, status, or free-text prompt/response content. Use this for LLM-specific investigations rather than raw span search.",
220
+ parameters: Schema.Struct({
221
+ service: ServiceParam,
222
+ traceId: Schema.optional(Schema.String.annotate({ description: "Filter by trace ID." })),
223
+ sessionId: Schema.optional(Schema.String.annotate({ description: "Filter by normalized AI sessionId." })),
224
+ functionId: Schema.optional(Schema.String.annotate({ description: "Filter by AI functionId, e.g. session.llm." })),
225
+ provider: Schema.optional(Schema.String.annotate({ description: "Filter by provider, e.g. openai.responses." })),
226
+ model: Schema.optional(Schema.String.annotate({ description: "Filter by model ID." })),
227
+ operation: Schema.optional(Schema.String.annotate({ description: "Filter by normalized AI operation, e.g. streamText." })),
228
+ status: Status,
229
+ minDurationMs: Schema.optional(Schema.Number.annotate({ description: "Only return AI calls slower than this (ms)." })),
230
+ text: Schema.optional(Schema.String.annotate({ description: "Case-insensitive substring match across prompt, response, and tool content." })),
231
+ lookback: Lookback,
232
+ limit: Limit,
233
+ }),
234
+ success: Schema.Unknown,
235
+ }).annotate(Tool.Readonly, true)
236
+
237
+ const GetAiCallTool = Tool.make("motel_get_ai_call", {
238
+ description:
239
+ "Fetch the full detail for one AI call by spanId, including complete prompt messages, response payloads, tool calls, token usage, provider metadata, and correlated logs.",
240
+ parameters: Schema.Struct({
241
+ spanId: Schema.String.annotate({ description: "The span ID of the AI call." }),
242
+ }),
243
+ success: Schema.Unknown,
244
+ }).annotate(Tool.Readonly, true)
245
+
246
+ const AiStatsTool = Tool.make("motel_ai_stats", {
247
+ description:
248
+ "Aggregate AI call statistics grouped by provider, model, functionId, sessionId, or status. Use this before paging raw AI calls when you want to understand which models are slowest or which functions consume the most tokens.",
249
+ parameters: Schema.Struct({
250
+ groupBy: Schema.Literals(["provider", "model", "functionId", "sessionId", "status"]),
251
+ agg: Schema.Literals(["count", "avg_duration", "p95_duration", "total_input_tokens", "total_output_tokens"]),
252
+ service: ServiceParam,
253
+ traceId: Schema.optional(Schema.String),
254
+ sessionId: Schema.optional(Schema.String),
255
+ functionId: Schema.optional(Schema.String),
256
+ provider: Schema.optional(Schema.String),
257
+ model: Schema.optional(Schema.String),
258
+ operation: Schema.optional(Schema.String),
259
+ status: Status,
260
+ minDurationMs: Schema.optional(Schema.Number),
261
+ lookback: Lookback,
262
+ limit: Limit,
263
+ }),
264
+ success: Schema.Unknown,
265
+ }).annotate(Tool.Readonly, true)
266
+
267
+ const DocsIndexTool = Tool.make("motel_docs_index", {
268
+ description:
269
+ "List the documentation pages bundled with motel, such as the debug workflow and Effect guide. Use this before motel_get_doc if you are unsure which docs are available.",
270
+ parameters: Tool.EmptyParams,
271
+ success: Schema.Unknown,
272
+ }).annotate(Tool.Readonly, true)
273
+
274
+ const GetDocTool = Tool.make("motel_get_doc", {
275
+ description:
276
+ "Fetch a bundled motel documentation page as markdown text. Useful for giving an agent the exact debug workflow or Effect instrumentation guidance without leaving MCP.",
277
+ parameters: Schema.Struct({
278
+ name: Schema.String.annotate({ description: "Document name, e.g. 'debug' or 'effect'." }),
279
+ }),
280
+ success: Schema.Unknown,
281
+ }).annotate(Tool.Readonly, true)
282
+
283
+ const OpenApiTool = Tool.make("motel_openapi", {
284
+ description:
285
+ "Fetch motel's OpenAPI JSON document. Use this when you need the authoritative HTTP API surface or want to compare MCP coverage against the server routes.",
286
+ parameters: Tool.EmptyParams,
287
+ success: Schema.Unknown,
288
+ }).annotate(Tool.Readonly, true)
289
+
290
+ const TraceStatsTool = Tool.make("motel_traces_stats", {
291
+ description:
292
+ "Aggregate statistics across traces: count, average duration, p95 duration, or error rate, grouped by a field like service, operation, status, or attr.<key>. Use this BEFORE paginating raw traces when you want to understand the shape of the data — for example 'what tools are the slowest' or 'which services are erroring'.",
293
+ parameters: Schema.Struct({
294
+ groupBy: Schema.String.annotate({
295
+ description: "Grouping dimension. Examples: 'service', 'operation', 'status', 'attr.tool.name'.",
296
+ }),
297
+ agg: Schema.Literals(["count", "avg_duration", "p95_duration", "error_rate"]),
298
+ service: ServiceParam,
299
+ operation: Schema.optional(Schema.String),
300
+ status: Status,
301
+ minDurationMs: Schema.optional(Schema.Number),
302
+ attributes: Attributes,
303
+ lookback: Lookback,
304
+ limit: Limit,
305
+ }),
306
+ success: Schema.Unknown,
307
+ }).annotate(Tool.Readonly, true)
308
+
309
+ const LogStatsTool = Tool.make("motel_logs_stats", {
310
+ description:
311
+ "Group and count logs by a field like 'severity', 'service', 'scope', or 'attr.<key>'. Useful for quickly understanding log-level distribution (e.g. how many ERROR logs there are in the last hour) before drilling into individual entries.",
312
+ parameters: Schema.Struct({
313
+ groupBy: Schema.String.annotate({
314
+ description: "Grouping dimension. Examples: 'service', 'severity', 'scope', 'attr.session.id'.",
315
+ }),
316
+ service: ServiceParam,
317
+ traceId: Schema.optional(Schema.String),
318
+ spanId: Schema.optional(Schema.String),
319
+ body: Schema.optional(Schema.String),
320
+ attributes: Attributes,
321
+ lookback: Lookback,
322
+ limit: Limit,
323
+ }),
324
+ success: Schema.Unknown,
325
+ }).annotate(Tool.Readonly, true)
326
+
327
+ const MotelToolkit = Toolkit.make(
328
+ StatusTool,
329
+ ServicesTool,
330
+ FacetsTool,
331
+ SearchTracesTool,
332
+ GetTraceTool,
333
+ GetTraceLogsTool,
334
+ GetTraceSpansTool,
335
+ SearchSpansTool,
336
+ GetSpanTool,
337
+ GetSpanLogsTool,
338
+ SearchLogsTool,
339
+ SearchAiCallsTool,
340
+ GetAiCallTool,
341
+ AiStatsTool,
342
+ TraceStatsTool,
343
+ LogStatsTool,
344
+ DocsIndexTool,
345
+ GetDocTool,
346
+ OpenApiTool,
347
+ )
348
+
349
+ const asResult = <A>(effect: Effect.Effect<A, { readonly message: string }>) =>
350
+ Effect.match(effect, {
351
+ onFailure: (err) => ({ error: err.message }) as unknown,
352
+ onSuccess: (value) => value as unknown,
353
+ })
354
+
355
+ const ToolHandlers = MotelToolkit.toLayer(
356
+ Effect.gen(function* () {
357
+ const client = yield* MotelClient
358
+ const locator = yield* Locator
359
+
360
+ return {
361
+ motel_status: () =>
362
+ Effect.match(locator.resolve, {
363
+ onFailure: (err) => ({
364
+ connected: false as const,
365
+ error: err instanceof Error ? err.message : String(err),
366
+ }),
367
+ onSuccess: (r) => ({
368
+ connected: true as const,
369
+ url: r.url,
370
+ version: r.version,
371
+ workdir: r.workdir,
372
+ cwdMatch: r.cwdMatch,
373
+ instanceCount: r.instanceCount,
374
+ source: r.source,
375
+ }),
376
+ }),
377
+
378
+ motel_services: () => asResult(client.services),
379
+
380
+ motel_facets: (input) => asResult(client.facets(input)),
381
+
382
+ motel_search_traces: (input) => asResult(client.searchTraces(input)),
383
+
384
+ motel_get_trace: ({ traceId }) => asResult(client.getTrace(traceId)),
385
+
386
+ motel_get_trace_logs: ({ traceId, lookback, limit, cursor }) =>
387
+ asResult(client.getTraceLogs(traceId, { lookback, limit, cursor })),
388
+
389
+ motel_get_trace_spans: ({ traceId }) => asResult(client.getTraceSpans(traceId)),
390
+
391
+ motel_search_spans: (input) => asResult(client.searchSpans(input)),
392
+
393
+ motel_get_span: ({ spanId }) => asResult(client.getSpan(spanId)),
394
+
395
+ motel_get_span_logs: ({ spanId, lookback, limit, cursor }) =>
396
+ asResult(client.getSpanLogs(spanId, { lookback, limit, cursor })),
397
+
398
+ motel_search_logs: (input) => asResult(client.searchLogs(input)),
399
+
400
+ motel_search_ai_calls: (input) => asResult(client.searchAiCalls(input)),
401
+
402
+ motel_get_ai_call: ({ spanId }) => asResult(client.getAiCall(spanId)),
403
+
404
+ motel_ai_stats: (input) => asResult(client.aiCallStats(input)),
405
+
406
+ motel_traces_stats: (input) => asResult(client.traceStats(input)),
407
+
408
+ motel_logs_stats: (input) => asResult(client.logStats(input)),
409
+
410
+ motel_docs_index: () => asResult(client.docs),
411
+
412
+ motel_get_doc: ({ name }) => asResult(Effect.map(client.getDoc(name), (data) => ({ data }))),
413
+
414
+ motel_openapi: () => asResult(client.openapi),
415
+ }
416
+ }),
417
+ )
418
+
419
+ const ServerLayer = McpServer.toolkit(MotelToolkit).pipe(
420
+ Layer.provideMerge(ToolHandlers),
421
+ Layer.provide(MotelClientLive),
422
+ Layer.provide(LocatorLive),
423
+ Layer.provide(
424
+ McpServer.layerStdio({
425
+ name: "motel",
426
+ version: MOTEL_VERSION,
427
+ }),
428
+ ),
429
+ Layer.provide(BunStdio.layer),
430
+ Layer.provide(Logger.layer([Logger.consolePretty({ stderr: true })])),
431
+ )
432
+
433
+ Layer.launch(ServerLayer).pipe(BunRuntime.runMain)