@lovable.dev/mcp-js 0.13.0 → 0.15.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 (39) hide show
  1. package/README.md +31 -1
  2. package/dist/{chunk-SIITHGUD.js → chunk-LWHVXXKQ.js} +1 -1
  3. package/dist/{chunk-MA5H6PSF.js → chunk-P3F324UC.js} +34 -0
  4. package/dist/{chunk-WLNT2FX7.js → chunk-RHDEW56Y.js} +18 -4
  5. package/dist/{chunk-YWLMMDET.js → chunk-T2RED2TG.js} +15 -2
  6. package/dist/chunk-XQSTN54Y.js +189 -0
  7. package/dist/cli/extract-manifest.cjs +1 -1
  8. package/dist/cli/extract-manifest.js +3 -3
  9. package/dist/index.cjs +33 -0
  10. package/dist/index.d.cts +2 -2
  11. package/dist/index.d.ts +2 -2
  12. package/dist/index.js +4 -2
  13. package/dist/protocols/mcp/index.cjs +221 -3
  14. package/dist/protocols/mcp/index.d.cts +3 -2
  15. package/dist/protocols/mcp/index.d.ts +3 -2
  16. package/dist/protocols/mcp/index.js +4 -2
  17. package/dist/protocols/oauth-metadata.d.cts +1 -1
  18. package/dist/protocols/oauth-metadata.d.ts +1 -1
  19. package/dist/protocols/rest/index.cjs +218 -1
  20. package/dist/protocols/rest/index.d.cts +3 -2
  21. package/dist/protocols/rest/index.d.ts +3 -2
  22. package/dist/protocols/rest/index.js +4 -2
  23. package/dist/recorder-B-eJU4Qu.d.ts +21 -0
  24. package/dist/stacks/supabase/index.cjs +250 -22
  25. package/dist/stacks/supabase/index.d.cts +1 -1
  26. package/dist/stacks/supabase/index.d.ts +1 -1
  27. package/dist/stacks/supabase/index.js +10 -5
  28. package/dist/stacks/supabase/vite.cjs +1 -1
  29. package/dist/stacks/supabase/vite.js +3 -3
  30. package/dist/stacks/tanstack/index.cjs +231 -4
  31. package/dist/stacks/tanstack/index.d.cts +1 -1
  32. package/dist/stacks/tanstack/index.d.ts +1 -1
  33. package/dist/stacks/tanstack/index.js +5 -3
  34. package/dist/stacks/tanstack/vite.cjs +36 -22
  35. package/dist/stacks/tanstack/vite.d.cts +9 -7
  36. package/dist/stacks/tanstack/vite.d.ts +9 -7
  37. package/dist/stacks/tanstack/vite.js +36 -22
  38. package/dist/{types-BanzncFh.d.ts → types-COY42xux.d.ts} +30 -1
  39. package/package.json +1 -1
@@ -649,23 +649,240 @@ function corsPreflightResponse(allowMethods) {
649
649
  });
650
650
  }
651
651
 
652
+ // src/metrics/config.ts
653
+ var DEFAULT_METRICS_ENDPOINT = "https://api.lovable.dev/v1/app-mcp-usage";
654
+ var METRICS_API_KEY_ENV_VAR = "LOVABLE_API_KEY";
655
+ var METRICS_SAMPLE_RATE = 1;
656
+ var METRICS_FLUSH_INTERVAL_MS = 5e3;
657
+ var METRICS_MAX_BATCH_SIZE = 50;
658
+ function assertMetricsEndpoint(endpoint) {
659
+ let url;
660
+ try {
661
+ url = new URL(endpoint);
662
+ } catch {
663
+ throw new Error(`@lovable.dev/mcp-js: metrics.endpoint must be an absolute URL, got ${JSON.stringify(endpoint)}`);
664
+ }
665
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
666
+ throw new Error(`@lovable.dev/mcp-js: metrics.endpoint must be http(s), got ${JSON.stringify(endpoint)}`);
667
+ }
668
+ }
669
+ function resolveMetricsConfig(config = true) {
670
+ const options = typeof config === "boolean" ? { enabled: config } : config;
671
+ const endpoint = options.endpoint ?? DEFAULT_METRICS_ENDPOINT;
672
+ assertMetricsEndpoint(endpoint);
673
+ return Object.freeze({
674
+ enabled: options.enabled ?? true,
675
+ endpoint,
676
+ headers: options.headers ?? {},
677
+ apiKeyEnvVar: METRICS_API_KEY_ENV_VAR,
678
+ sampleRate: METRICS_SAMPLE_RATE,
679
+ flushIntervalMs: METRICS_FLUSH_INTERVAL_MS,
680
+ maxBatchSize: METRICS_MAX_BATCH_SIZE
681
+ });
682
+ }
683
+
684
+ // package.json
685
+ var version = "0.15.0";
686
+
687
+ // src/metrics/otlp.ts
688
+ var SCOPE_NAME = "@lovable.dev/mcp-js";
689
+ var EVENT_NAME = "mcp.tool.invocation";
690
+ var SEVERITY_INFO = 9;
691
+ function strAttr(key, value) {
692
+ return { key, value: { stringValue: value } };
693
+ }
694
+ function intAttr(key, value) {
695
+ return { key, value: { intValue: String(Math.round(value)) } };
696
+ }
697
+ function toLogRecord(rec) {
698
+ const attributes = [
699
+ strAttr("event.name", EVENT_NAME),
700
+ strAttr("mcp.method", rec.method),
701
+ strAttr("mcp.outcome", rec.outcome),
702
+ intAttr("mcp.duration_ms", rec.durationMs)
703
+ ];
704
+ if (rec.tool !== null)
705
+ attributes.push(strAttr("mcp.tool", rec.tool));
706
+ if (rec.reqBytes !== void 0)
707
+ attributes.push(intAttr("mcp.request_size_bytes", rec.reqBytes));
708
+ if (rec.resBytes !== void 0)
709
+ attributes.push(intAttr("mcp.response_size_bytes", rec.resBytes));
710
+ return {
711
+ timeUnixNano: rec.timeUnixNano,
712
+ observedTimeUnixNano: rec.timeUnixNano,
713
+ severityNumber: SEVERITY_INFO,
714
+ severityText: "INFO",
715
+ body: { stringValue: EVENT_NAME },
716
+ attributes
717
+ };
718
+ }
719
+ function buildLogsPayload(records, server) {
720
+ return JSON.stringify({
721
+ resourceLogs: [
722
+ {
723
+ resource: {
724
+ attributes: [
725
+ strAttr("service.name", server.name),
726
+ strAttr("service.version", server.version),
727
+ strAttr("telemetry.sdk.name", SCOPE_NAME),
728
+ strAttr("telemetry.sdk.version", version),
729
+ strAttr("telemetry.sdk.language", "webjs")
730
+ ]
731
+ },
732
+ scopeLogs: [
733
+ {
734
+ scope: { name: SCOPE_NAME, version },
735
+ logRecords: records.map(toLogRecord)
736
+ }
737
+ ]
738
+ }
739
+ ]
740
+ });
741
+ }
742
+ function nowUnixNano() {
743
+ return `${Date.now()}000000`;
744
+ }
745
+
746
+ // src/metrics/recorder.ts
747
+ function nowMs() {
748
+ return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
749
+ }
750
+ var NOOP_RECORDER = {
751
+ record() {
752
+ },
753
+ async flush() {
754
+ }
755
+ };
756
+ function createNoopRecorder() {
757
+ return NOOP_RECORDER;
758
+ }
759
+ function readRuntimeEnv(name) {
760
+ try {
761
+ const denoEnv = globalThis.Deno?.env;
762
+ const value = denoEnv?.get?.(name);
763
+ if (value)
764
+ return value;
765
+ } catch {
766
+ }
767
+ try {
768
+ if (typeof process !== "undefined") {
769
+ const value = process.env?.[name];
770
+ if (value)
771
+ return value;
772
+ }
773
+ } catch {
774
+ }
775
+ return void 0;
776
+ }
777
+ function probeWaitUntil() {
778
+ const fn = globalThis.EdgeRuntime?.waitUntil;
779
+ return typeof fn === "function" ? fn.bind(globalThis.EdgeRuntime) : void 0;
780
+ }
781
+ function createMetricsRecorder(ctx, deps = {}) {
782
+ const { config, server } = ctx;
783
+ if (!config.enabled)
784
+ return NOOP_RECORDER;
785
+ const doFetch = deps.fetch ?? globalThis.fetch;
786
+ if (!doFetch)
787
+ return NOOP_RECORDER;
788
+ const usesLovableEndpoint = config.endpoint === DEFAULT_METRICS_ENDPOINT;
789
+ const apiKey = usesLovableEndpoint ? (deps.getApiKey ?? (() => readRuntimeEnv(config.apiKeyEnvVar)))() : void 0;
790
+ if (usesLovableEndpoint && !apiKey) {
791
+ log.debug("metrics.disabled_no_api_key", { envVar: config.apiKeyEnvVar });
792
+ return NOOP_RECORDER;
793
+ }
794
+ const waitUntil = deps.waitUntil ?? probeWaitUntil();
795
+ const buffer = [];
796
+ let timer;
797
+ const schedule = (p) => {
798
+ if (waitUntil) {
799
+ try {
800
+ waitUntil(p);
801
+ return;
802
+ } catch {
803
+ }
804
+ }
805
+ void p.catch(() => {
806
+ });
807
+ };
808
+ const headers = {};
809
+ if (!usesLovableEndpoint) {
810
+ for (const [key, value] of Object.entries(config.headers)) {
811
+ if (key.toLowerCase() !== "content-type")
812
+ headers[key] = value;
813
+ }
814
+ }
815
+ headers["content-type"] = "application/json";
816
+ if (apiKey)
817
+ headers.authorization = `Bearer ${apiKey}`;
818
+ const flush = async () => {
819
+ if (buffer.length === 0)
820
+ return;
821
+ const records = buffer.splice(0, buffer.length);
822
+ try {
823
+ const res = await doFetch(config.endpoint, {
824
+ method: "POST",
825
+ headers,
826
+ body: buildLogsPayload(records, server),
827
+ keepalive: true
828
+ });
829
+ if (!res.ok)
830
+ log.debug("metrics.flush_rejected", { status: res.status });
831
+ } catch (err) {
832
+ log.debug("metrics.flush_failed", describeError(err));
833
+ }
834
+ };
835
+ const ensureTimer = () => {
836
+ if (timer !== void 0)
837
+ return;
838
+ timer = setInterval(() => schedule(flush()), config.flushIntervalMs);
839
+ timer.unref?.();
840
+ };
841
+ return {
842
+ record(ev) {
843
+ if (config.sampleRate < 1 && Math.random() >= config.sampleRate)
844
+ return;
845
+ buffer.push({ ...ev, timeUnixNano: nowUnixNano() });
846
+ ensureTimer();
847
+ if (buffer.length >= config.maxBatchSize)
848
+ schedule(flush());
849
+ },
850
+ flush
851
+ };
852
+ }
853
+ function createRecorderForRuntime(mcp) {
854
+ const config = resolveMetricsConfig(mcp.metrics);
855
+ if (!config.enabled)
856
+ return createNoopRecorder();
857
+ return createMetricsRecorder({ config, server: { name: mcp.name, version: mcp.version } });
858
+ }
859
+
652
860
  // src/protocols/mcp/protocol.ts
653
- function adaptToolToSdkCallback(tool, auth) {
861
+ function adaptToolToSdkCallback(tool, auth, recorder) {
654
862
  return async (first) => {
655
863
  const args = tool.inputSchema ? first ?? {} : {};
864
+ const start = nowMs();
656
865
  let result;
657
866
  try {
658
867
  result = await tool.handler(args, new ToolContext(auth));
659
868
  } catch {
869
+ recorder.record({ tool: tool.name, method: "tools/call", outcome: "handler_error", durationMs: nowMs() - start });
660
870
  return { content: [{ type: "text", text: "tool execution failed" }], isError: true };
661
871
  }
662
872
  if (result == null) {
873
+ recorder.record({ tool: tool.name, method: "tools/call", outcome: "handler_error", durationMs: nowMs() - start });
663
874
  return { content: [{ type: "text", text: `tool "${tool.name}" returned no result` }], isError: true };
664
875
  }
876
+ recorder.record({
877
+ tool: tool.name,
878
+ method: "tools/call",
879
+ outcome: result.isError ? "tool_error" : "ok",
880
+ durationMs: nowMs() - start
881
+ });
665
882
  return { content: result.content ?? [], structuredContent: result.structuredContent, isError: result.isError };
666
883
  };
667
884
  }
668
- function createMcpProtocolHandler(mcp, options = {}) {
885
+ function createMcpProtocolHandler(mcp, options = {}, recorder = createRecorderForRuntime(mcp)) {
669
886
  const authorizer = createRequestAuthorizer(mcp, options);
670
887
  const handle = async (request) => {
671
888
  const authResult = await authorizer.authorize(request);
@@ -686,7 +903,7 @@ function createMcpProtocolHandler(mcp, options = {}) {
686
903
  outputSchema: tool.outputSchema,
687
904
  annotations: tool.annotations
688
905
  },
689
- adaptToolToSdkCallback(tool, authResult.auth)
906
+ adaptToolToSdkCallback(tool, authResult.auth, recorder)
690
907
  );
691
908
  }
692
909
  const transport = new import_webStandardStreamableHttp.WebStandardStreamableHTTPServerTransport({
@@ -695,6 +912,7 @@ function createMcpProtocolHandler(mcp, options = {}) {
695
912
  await server.connect(transport);
696
913
  return await transport.handleRequest(request);
697
914
  } catch (err) {
915
+ recorder.record({ tool: null, method: "transport", outcome: "transport_error", durationMs: 0 });
698
916
  log.error("mcp.transport_error", { ...describeError(err), outcome: "500 internal error" });
699
917
  return Response.json(
700
918
  { jsonrpc: "2.0", id: null, error: { code: -32603, message: "internal error" } },
@@ -828,7 +1046,7 @@ function isEmptyArgs(value) {
828
1046
  return false;
829
1047
  return Object.keys(value).length === 0;
830
1048
  }
831
- function createInvokeToolHandler(mcp, options = {}) {
1049
+ function createInvokeToolHandler(mcp, options = {}, recorder = createRecorderForRuntime(mcp)) {
832
1050
  assertRestResourceBinding(mcp, options);
833
1051
  const authorizer = createRequestAuthorizer(mcp, options);
834
1052
  const handle = async (request, toolName) => {
@@ -884,20 +1102,29 @@ function createInvokeToolHandler(mcp, options = {}) {
884
1102
  });
885
1103
  }
886
1104
  let result;
1105
+ const start = nowMs();
887
1106
  try {
888
1107
  result = await tool.handler(args, new ToolContext(authResult.auth));
889
1108
  } catch {
1109
+ recorder.record({ tool: tool.name, method: "tools/call", outcome: "handler_error", durationMs: nowMs() - start });
890
1110
  return new Response(JSON.stringify({ error: "handler threw", tool: toolName }), {
891
1111
  status: 500,
892
1112
  headers: JSON_HEADERS
893
1113
  });
894
1114
  }
895
1115
  if (result == null) {
1116
+ recorder.record({ tool: tool.name, method: "tools/call", outcome: "handler_error", durationMs: nowMs() - start });
896
1117
  return new Response(JSON.stringify({ error: `tool "${toolName}" returned no result` }), {
897
1118
  status: 500,
898
1119
  headers: JSON_HEADERS
899
1120
  });
900
1121
  }
1122
+ recorder.record({
1123
+ tool: tool.name,
1124
+ method: "tools/call",
1125
+ outcome: result.isError ? "tool_error" : "ok",
1126
+ durationMs: nowMs() - start
1127
+ });
901
1128
  return Response.json({
902
1129
  content: result.content ?? [],
903
1130
  structuredContent: result.structuredContent,
@@ -1,5 +1,5 @@
1
1
  import { M as McpRuntimeOptions } from '../../authorize-BMeXd_Sh.js';
2
- import { d as McpDefinition } from '../../types-BanzncFh.js';
2
+ import { d as McpDefinition } from '../../types-COY42xux.js';
3
3
  import 'zod';
4
4
 
5
5
  interface TanStackRouteCtx {
@@ -1,5 +1,5 @@
1
1
  import { M as McpRuntimeOptions } from '../../authorize-BMeXd_Sh.js';
2
- import { d as McpDefinition } from '../../types-BanzncFh.js';
2
+ import { d as McpDefinition } from '../../types-COY42xux.js';
3
3
  import 'zod';
4
4
 
5
5
  interface TanStackRouteCtx {
@@ -3,20 +3,22 @@ import {
3
3
  } from "../../chunk-UQK5UO6C.js";
4
4
  import {
5
5
  createMcpProtocolHandler
6
- } from "../../chunk-WLNT2FX7.js";
6
+ } from "../../chunk-RHDEW56Y.js";
7
7
  import {
8
8
  createOAuthProtectedResourceMetadataHandler
9
9
  } from "../../chunk-QDKOF4UF.js";
10
10
  import {
11
11
  createInvokeToolHandler
12
- } from "../../chunk-YWLMMDET.js";
12
+ } from "../../chunk-T2RED2TG.js";
13
13
  import {
14
14
  createListToolsHandler
15
15
  } from "../../chunk-VNRQPA4K.js";
16
- import "../../chunk-MA5H6PSF.js";
16
+ import "../../chunk-XQSTN54Y.js";
17
+ import "../../chunk-P3F324UC.js";
17
18
  import "../../chunk-G4XA7IJM.js";
18
19
  import "../../chunk-QC3DXQTH.js";
19
20
  import "../../chunk-6DXGZZA4.js";
21
+ import "../../chunk-LWHVXXKQ.js";
20
22
 
21
23
  // src/stacks/tanstack/handlers.ts
22
24
  function forwarded(request, options) {
@@ -56,20 +56,28 @@ function wellKnownRouteFile(routesDir, urlPath) {
56
56
  });
57
57
  return (0, import_node_path.resolve)(routesDir, ...fileSegments);
58
58
  }
59
- function resolveAllRoutes(projectRoot, routesDirOption, routeFileName, metadataUrlPath) {
59
+ function resolveRestRoutes(routesDir, mcpUrlPath) {
60
+ const parentSegments = mcpUrlPath.replace(/^\/+/, "").replace(/\/+$/, "").split("/").slice(0, -1);
61
+ const restDir = (0, import_node_path.resolve)(routesDir, ...parentSegments, "[.mcp]");
62
+ const restUrlBase = `/${[...parentSegments, ".mcp"].join("/")}`;
63
+ return {
64
+ listToolsRouteFile: (0, import_node_path.resolve)(restDir, "list-tools.ts"),
65
+ invokeToolRouteFile: (0, import_node_path.resolve)(restDir, "invoke-tool", "$tool.ts"),
66
+ listToolsLiteral: `${restUrlBase}/list-tools`,
67
+ invokeToolLiteral: `${restUrlBase}/invoke-tool/$tool`
68
+ };
69
+ }
70
+ function resolveAllRoutes(projectRoot, routesDirOption, routeFileName, mcpUrlPath, metadataUrlPath) {
60
71
  const routesDir = (0, import_node_path.resolve)(projectRoot, routesDirOption);
61
72
  assertContains(projectRoot, routesDir, `routesDir "${routesDirOption}"`);
62
73
  const mcpRouteFile = (0, import_node_path.resolve)(routesDir, routeFileName);
63
74
  assertContains(routesDir, mcpRouteFile, `routeFileName "${routeFileName}"`);
64
75
  const metadataRouteFile = wellKnownRouteFile(routesDir, metadataUrlPath);
65
76
  assertContains(routesDir, metadataRouteFile, `metadataPath "${metadataUrlPath}"`);
66
- return {
67
- routesDir,
68
- mcpRouteFile,
69
- metadataRouteFile,
70
- listToolsRouteFile: (0, import_node_path.resolve)(routesDir, "[.mcp]", "list-tools.ts"),
71
- invokeToolRouteFile: (0, import_node_path.resolve)(routesDir, "[.mcp]", "invoke-tool", "$tool.ts")
72
- };
77
+ const rest = resolveRestRoutes(routesDir, mcpUrlPath);
78
+ assertContains(routesDir, rest.listToolsRouteFile, `REST routes for path "${mcpUrlPath}"`);
79
+ assertContains(routesDir, rest.invokeToolRouteFile, `REST routes for path "${mcpUrlPath}"`);
80
+ return { routesDir, mcpRouteFile, metadataRouteFile, ...rest };
73
81
  }
74
82
  function assertUrlPathShape(urlPath) {
75
83
  if (!urlPath.startsWith("/")) {
@@ -231,12 +239,15 @@ function mcpPlugin(options = {}) {
231
239
  const trustForwardedHost = options.trustForwardedHost !== false;
232
240
  let projectRoot = process.cwd();
233
241
  let mcpEntry = (0, import_node_path.resolve)(projectRoot, mcpEntryOption);
234
- let { routesDir, mcpRouteFile, metadataRouteFile, listToolsRouteFile, invokeToolRouteFile } = resolveAllRoutes(
235
- projectRoot,
236
- routesDirOption,
237
- routeFileName,
238
- metadataUrlPath
239
- );
242
+ let {
243
+ routesDir,
244
+ mcpRouteFile,
245
+ metadataRouteFile,
246
+ listToolsRouteFile,
247
+ invokeToolRouteFile,
248
+ listToolsLiteral,
249
+ invokeToolLiteral
250
+ } = resolveAllRoutes(projectRoot, routesDirOption, routeFileName, urlPath, metadataUrlPath);
240
251
  const regenerate = () => {
241
252
  let mcpEntryExists = true;
242
253
  try {
@@ -262,13 +273,13 @@ function mcpPlugin(options = {}) {
262
273
  routes.push(
263
274
  {
264
275
  file: listToolsRouteFile,
265
- routeLiteral: "/.mcp/list-tools",
276
+ routeLiteral: listToolsLiteral,
266
277
  handlerFactory: "createTanStackListToolsHandler",
267
278
  spaFallbackNote: true
268
279
  },
269
280
  {
270
281
  file: invokeToolRouteFile,
271
- routeLiteral: "/.mcp/invoke-tool/$tool",
282
+ routeLiteral: invokeToolLiteral,
272
283
  handlerFactory: "createTanStackInvokeToolHandler",
273
284
  spaFallbackNote: true
274
285
  }
@@ -310,12 +321,15 @@ function mcpPlugin(options = {}) {
310
321
  configResolved(config) {
311
322
  projectRoot = config.root;
312
323
  mcpEntry = (0, import_node_path.resolve)(projectRoot, mcpEntryOption);
313
- ({ routesDir, mcpRouteFile, metadataRouteFile, listToolsRouteFile, invokeToolRouteFile } = resolveAllRoutes(
314
- projectRoot,
315
- routesDirOption,
316
- routeFileName,
317
- metadataUrlPath
318
- ));
324
+ ({
325
+ routesDir,
326
+ mcpRouteFile,
327
+ metadataRouteFile,
328
+ listToolsRouteFile,
329
+ invokeToolRouteFile,
330
+ listToolsLiteral,
331
+ invokeToolLiteral
332
+ } = resolveAllRoutes(projectRoot, routesDirOption, routeFileName, urlPath, metadataUrlPath));
319
333
  regenerate();
320
334
  },
321
335
  configureServer(server) {
@@ -16,13 +16,15 @@ interface McpPluginOptions {
16
16
  */
17
17
  routesDir?: string;
18
18
  /**
19
- * Public URL path for the MCP-protocol route only.
19
+ * Public URL path for the MCP-protocol route.
20
20
  *
21
- * The REST companion routes are fixed at `/.mcp/list-tools` and
22
- * `/.mcp/invoke-tool/$tool` and are not affected by this option the
23
- * `[.mcp]` namespace exists to keep them from colliding with
24
- * user-defined URLs, so making them follow `path` would defeat the
25
- * purpose. Set `restRoutes: false` to drop the companions entirely.
21
+ * The REST companion routes co-locate with this path under a `.mcp`
22
+ * sub-segment of the same parent prefix: `/mcp` keeps them at
23
+ * `/.mcp/list-tools` and `/.mcp/invoke-tool/$tool`, while
24
+ * `/api/public/mcp` moves them to `/api/public/.mcp/*`. The `.mcp`
25
+ * namespace keeps them from colliding with user-defined URLs without
26
+ * anchoring them at the root. Set `restRoutes: false` to drop the
27
+ * companions entirely.
26
28
  *
27
29
  * Must start with `/` and use only `/mcp`-style segments; anything more
28
30
  * exotic doesn't map cleanly to a TanStack file name.
@@ -37,7 +39,7 @@ interface McpPluginOptions {
37
39
  routeFileName?: string;
38
40
  /**
39
41
  * Set to `false` to skip emitting the REST companion routes at
40
- * `/.mcp/list-tools` and `/.mcp/<tool>`. Set
42
+ * `<path-parent>/.mcp/list-tools` and `<path-parent>/.mcp/<tool>`. Set
41
43
  * `protectedResourceMetadataRoute: false` as well if you want the MCP
42
44
  * protocol route to be the only emitted surface.
43
45
  * @default true
@@ -16,13 +16,15 @@ interface McpPluginOptions {
16
16
  */
17
17
  routesDir?: string;
18
18
  /**
19
- * Public URL path for the MCP-protocol route only.
19
+ * Public URL path for the MCP-protocol route.
20
20
  *
21
- * The REST companion routes are fixed at `/.mcp/list-tools` and
22
- * `/.mcp/invoke-tool/$tool` and are not affected by this option the
23
- * `[.mcp]` namespace exists to keep them from colliding with
24
- * user-defined URLs, so making them follow `path` would defeat the
25
- * purpose. Set `restRoutes: false` to drop the companions entirely.
21
+ * The REST companion routes co-locate with this path under a `.mcp`
22
+ * sub-segment of the same parent prefix: `/mcp` keeps them at
23
+ * `/.mcp/list-tools` and `/.mcp/invoke-tool/$tool`, while
24
+ * `/api/public/mcp` moves them to `/api/public/.mcp/*`. The `.mcp`
25
+ * namespace keeps them from colliding with user-defined URLs without
26
+ * anchoring them at the root. Set `restRoutes: false` to drop the
27
+ * companions entirely.
26
28
  *
27
29
  * Must start with `/` and use only `/mcp`-style segments; anything more
28
30
  * exotic doesn't map cleanly to a TanStack file name.
@@ -37,7 +39,7 @@ interface McpPluginOptions {
37
39
  routeFileName?: string;
38
40
  /**
39
41
  * Set to `false` to skip emitting the REST companion routes at
40
- * `/.mcp/list-tools` and `/.mcp/<tool>`. Set
42
+ * `<path-parent>/.mcp/list-tools` and `<path-parent>/.mcp/<tool>`. Set
41
43
  * `protectedResourceMetadataRoute: false` as well if you want the MCP
42
44
  * protocol route to be the only emitted surface.
43
45
  * @default true
@@ -25,20 +25,28 @@ function wellKnownRouteFile(routesDir, urlPath) {
25
25
  });
26
26
  return resolve(routesDir, ...fileSegments);
27
27
  }
28
- function resolveAllRoutes(projectRoot, routesDirOption, routeFileName, metadataUrlPath) {
28
+ function resolveRestRoutes(routesDir, mcpUrlPath) {
29
+ const parentSegments = mcpUrlPath.replace(/^\/+/, "").replace(/\/+$/, "").split("/").slice(0, -1);
30
+ const restDir = resolve(routesDir, ...parentSegments, "[.mcp]");
31
+ const restUrlBase = `/${[...parentSegments, ".mcp"].join("/")}`;
32
+ return {
33
+ listToolsRouteFile: resolve(restDir, "list-tools.ts"),
34
+ invokeToolRouteFile: resolve(restDir, "invoke-tool", "$tool.ts"),
35
+ listToolsLiteral: `${restUrlBase}/list-tools`,
36
+ invokeToolLiteral: `${restUrlBase}/invoke-tool/$tool`
37
+ };
38
+ }
39
+ function resolveAllRoutes(projectRoot, routesDirOption, routeFileName, mcpUrlPath, metadataUrlPath) {
29
40
  const routesDir = resolve(projectRoot, routesDirOption);
30
41
  assertContains(projectRoot, routesDir, `routesDir "${routesDirOption}"`);
31
42
  const mcpRouteFile = resolve(routesDir, routeFileName);
32
43
  assertContains(routesDir, mcpRouteFile, `routeFileName "${routeFileName}"`);
33
44
  const metadataRouteFile = wellKnownRouteFile(routesDir, metadataUrlPath);
34
45
  assertContains(routesDir, metadataRouteFile, `metadataPath "${metadataUrlPath}"`);
35
- return {
36
- routesDir,
37
- mcpRouteFile,
38
- metadataRouteFile,
39
- listToolsRouteFile: resolve(routesDir, "[.mcp]", "list-tools.ts"),
40
- invokeToolRouteFile: resolve(routesDir, "[.mcp]", "invoke-tool", "$tool.ts")
41
- };
46
+ const rest = resolveRestRoutes(routesDir, mcpUrlPath);
47
+ assertContains(routesDir, rest.listToolsRouteFile, `REST routes for path "${mcpUrlPath}"`);
48
+ assertContains(routesDir, rest.invokeToolRouteFile, `REST routes for path "${mcpUrlPath}"`);
49
+ return { routesDir, mcpRouteFile, metadataRouteFile, ...rest };
42
50
  }
43
51
  function assertUrlPathShape(urlPath) {
44
52
  if (!urlPath.startsWith("/")) {
@@ -200,12 +208,15 @@ function mcpPlugin(options = {}) {
200
208
  const trustForwardedHost = options.trustForwardedHost !== false;
201
209
  let projectRoot = process.cwd();
202
210
  let mcpEntry = resolve(projectRoot, mcpEntryOption);
203
- let { routesDir, mcpRouteFile, metadataRouteFile, listToolsRouteFile, invokeToolRouteFile } = resolveAllRoutes(
204
- projectRoot,
205
- routesDirOption,
206
- routeFileName,
207
- metadataUrlPath
208
- );
211
+ let {
212
+ routesDir,
213
+ mcpRouteFile,
214
+ metadataRouteFile,
215
+ listToolsRouteFile,
216
+ invokeToolRouteFile,
217
+ listToolsLiteral,
218
+ invokeToolLiteral
219
+ } = resolveAllRoutes(projectRoot, routesDirOption, routeFileName, urlPath, metadataUrlPath);
209
220
  const regenerate = () => {
210
221
  let mcpEntryExists = true;
211
222
  try {
@@ -231,13 +242,13 @@ function mcpPlugin(options = {}) {
231
242
  routes.push(
232
243
  {
233
244
  file: listToolsRouteFile,
234
- routeLiteral: "/.mcp/list-tools",
245
+ routeLiteral: listToolsLiteral,
235
246
  handlerFactory: "createTanStackListToolsHandler",
236
247
  spaFallbackNote: true
237
248
  },
238
249
  {
239
250
  file: invokeToolRouteFile,
240
- routeLiteral: "/.mcp/invoke-tool/$tool",
251
+ routeLiteral: invokeToolLiteral,
241
252
  handlerFactory: "createTanStackInvokeToolHandler",
242
253
  spaFallbackNote: true
243
254
  }
@@ -279,12 +290,15 @@ function mcpPlugin(options = {}) {
279
290
  configResolved(config) {
280
291
  projectRoot = config.root;
281
292
  mcpEntry = resolve(projectRoot, mcpEntryOption);
282
- ({ routesDir, mcpRouteFile, metadataRouteFile, listToolsRouteFile, invokeToolRouteFile } = resolveAllRoutes(
283
- projectRoot,
284
- routesDirOption,
285
- routeFileName,
286
- metadataUrlPath
287
- ));
293
+ ({
294
+ routesDir,
295
+ mcpRouteFile,
296
+ metadataRouteFile,
297
+ listToolsRouteFile,
298
+ invokeToolRouteFile,
299
+ listToolsLiteral,
300
+ invokeToolLiteral
301
+ } = resolveAllRoutes(projectRoot, routesDirOption, routeFileName, urlPath, metadataUrlPath));
288
302
  regenerate();
289
303
  },
290
304
  configureServer(server) {
@@ -105,6 +105,29 @@ declare class ToolContext {
105
105
  getClaims(): JwtClaims | undefined;
106
106
  }
107
107
 
108
+ /**
109
+ * Usage-metrics options. Pass a boolean to the Vite plugins for the common
110
+ * on/off case, or this object to override the ingest endpoint (e.g. to
111
+ * self-host the collector outside Lovable).
112
+ */
113
+ interface MetricsOptions {
114
+ /** Master switch. Default `true`; metrics still self-disable at runtime when
115
+ * the API key env var is absent. */
116
+ enabled?: boolean;
117
+ /** OTLP/HTTP (JSON) logs URL to POST batches to — the default Lovable route or
118
+ * any OTLP collector. Must be an absolute http(s) URL.
119
+ * @default "https://api.lovable.dev/v1/app-mcp-usage" */
120
+ endpoint?: string;
121
+ /** Extra request headers — e.g. the auth token for a private OTLP collector.
122
+ * Sent verbatim with every batch (and baked into the build output, so source
123
+ * your own build-time env for secrets). Ignored for the default Lovable
124
+ * endpoint, which authenticates with `LOVABLE_API_KEY` instead. `content-type`
125
+ * cannot be overridden (OTLP/HTTP JSON requires `application/json`). */
126
+ headers?: Record<string, string>;
127
+ }
128
+ /** Either the bare on/off toggle or the full options object. */
129
+ type MetricsConfig = boolean | MetricsOptions;
130
+
108
131
  /**
109
132
  * Any zod schema. Aliased to zod's `ZodType` (no generics) — non-deprecated
110
133
  * in both v3 and v4. Use this when you need to type an individual schema;
@@ -273,10 +296,16 @@ interface McpDefinitionInput {
273
296
  */
274
297
  readonly instructions: string;
275
298
  readonly tools: readonly AnyToolDefinition[];
299
+ /**
300
+ * Usage-metrics emission (runtime telemetry), on by default. `false` opts out;
301
+ * `{ endpoint, headers }` sends to your own OTLP collector. Self-disables at
302
+ * runtime without `LOVABLE_API_KEY` on the default Lovable endpoint.
303
+ */
304
+ readonly metrics?: MetricsConfig;
276
305
  }
277
306
  declare const McpDefinitionBrand: unique symbol;
278
307
  type McpDefinition = McpDefinitionInput & {
279
308
  readonly [McpDefinitionBrand]: never;
280
309
  };
281
310
 
282
- export { type AudioContent as A, type ContentAnnotations as C, type EmbeddedBlobResource as E, type ImageContent as I, type JwtClaims as J, type McpAuthConfig as M, type ResourceLink as R, type TextContent as T, type ZodRawShape as Z, type ContentBlock as a, type EmbeddedResource as b, type EmbeddedTextResource as c, type McpDefinition as d, type McpDefinitionInput as e, type ResourceLinkIcon as f, type ToolAnnotations as g, type ToolContent as h, ToolContext as i, type ToolDefinition as j, type ToolHandlerResult as k, type ZodSchema as l };
311
+ export { type AudioContent as A, type ContentAnnotations as C, type EmbeddedBlobResource as E, type ImageContent as I, type JwtClaims as J, type McpAuthConfig as M, type ResourceLink as R, type TextContent as T, type ZodRawShape as Z, type ContentBlock as a, type EmbeddedResource as b, type EmbeddedTextResource as c, type McpDefinition as d, type McpDefinitionInput as e, type MetricsConfig as f, type MetricsOptions as g, type ResourceLinkIcon as h, type ToolAnnotations as i, type ToolContent as j, ToolContext as k, type ToolDefinition as l, type ToolHandlerResult as m, type ZodSchema as n };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lovable.dev/mcp-js",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Author MCP servers for Lovable apps. Declare tools with defineTool, register them in defineMcp, and a framework adapter (TanStack or Supabase Edge Functions) emits the route(s) at build time.",
5
5
  "type": "module",
6
6
  "repository": {