Skip to content

feat(cli): prototype stub-mode fern mcp init/list/tools/dev flow - #17544

Open
matlegault wants to merge 13 commits into
mainfrom
devin/1787772708-mcp-cli-prototype
Open

feat(cli): prototype stub-mode fern mcp init/list/tools/dev flow#17544
matlegault wants to merge 13 commits into
mainfrom
devin/1787772708-mcp-cli-prototype

Conversation

@matlegault

@matlegault matlegault commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Description

UX prototype — all backend functionality is stubbed. This PR adds a representative stub-mode prototype of the customer-facing MCP creation flow so the full UX path can be demoed end-to-end. Real CLI commands, real OpenAPI spec parsing, and real generators.yml writing — but the costing service, AI curation, code generation, and MCP runtime are all stubbed locally. No backend credentials are required.

New subcommands under fern mcp (alongside the existing fern mcp install):

  • fern mcp init — wizard: diagnoses the spec (title, endpoint count, token estimate), prompts for a server name, offers toolset presets (Read-only / Main resources / AI-curated / Everything, plus any existing tools.presets), shows tool count + token estimate + verdict per choice, routes amber/red verdicts into a trim loop (remove by tag/method/path-prefix), and writes an mcp group with a fernapi/fern-mcp-server entry into the workspace's real generators.yml. Noninteractive flags: --name, --preset, --intent, --group, --yes, --json (verdicts become warnings).
  • fern mcp list — lists MCP generator entries with group, server-name, output path, and resolved tool count/verdict.
  • fern mcp tools — prints the resolved tool surface for a group (--group, default mcp; --preset, --json), with --refine entering the same trim loop and rewriting the config in place.
  • fern mcp generate — prints a plausible stub generation transcript and writes tools.lock next to generators.yml (tool names, per-tool token estimates, deterministic local schema hash).
  • fern mcp dev — prints local dev / MCP Inspector guidance (npx @modelcontextprotocol/inspector ...).

All prototype logic lives under packages/cli/cli/src/commands/mcp/prototype/:

  • openapiSummary.ts — scans the workspace (incl. openapi/) for OpenAPI YAML/JSON, extracts endpoint summaries and rough token estimates.
  • toolset.ts — compound include/exclude selectors (fields ANDed within a selector, selectors ORed, exclude wins), tool resolution, and the two-clause verdict (tool count and token cost judged independently; default budget 40 tools / 60k tokens; amber ≤3× budget, red beyond).
  • presets.ts — read-only preset (GET + read-like POSTs by operationId/path/summary heuristics, ambiguous ones called out), main-resources preset backed by a multi-signal resource scorer (see below).
  • aiCurated.ts — stubbed "Fern Agent": pattern-matches the intent against tags/methods, presents exclusions first, persists the intent as intent: in YAML.
  • mcpGeneratorsYml.ts — parses/writes the MCP group in generators.yml (js-yaml, schema comment header, unrelated groups/keys preserved).
  • trimLoop.ts, workspace.ts, initMcp.ts, listMcp.ts, toolsMcp.ts, generateMcp.ts — command implementations.

Main-resources scorer (multi-signal)

buildMainResourcesPreset identifies resources from three signals — tags, first meaningful path segment after version prefixes (/v1, /api), and component-schema references from request/response bodies — and treats a candidate as credible when ≥2 signals agree (case/plural-insensitive canonical name matching). Each credible resource is scored as:

score = crudShapes.size × CRUD_SHAPE_WEIGHT        // list/get/create/update/delete detected from actual operation shapes
      + log2(activeEndpointCount + 1)              // log-scaled so chatty utility tags don't dominate
      + log2(schemaCentrality + 1)                 // other operations referencing the resource's schemas

Selection is threshold + budget, not top-N: resources above SCORE_THRESHOLD_RATIO (40%) of the top score are included, then the lowest-scoring resources are trimmed until computeVerdict is green — the preset is within budget by construction. Selectors are tag when tags exist, path-prefix compound selectors on untagged specs. Negative signals: name-regex exclusion (admin/internal/webhook/legacy/deprecated/beta/debug/test) plus operation-level deprecated: true and x-internal: true (captured per-endpoint in openapiSummary.ts), excluded via explicit endpoint selectors. A confidence gate marks the preset unavailable when no credible resources exist or when scores are flat (top < FLAT_SCORE_SEPARATION_RATIO (1.5×) median with no strong-CRUD resource).

Exception-based budget rendering

Human-facing output only "screams" budget when it's a problem. Within budget (green), option lines, fern mcp list, fern mcp tools, and the init/generate summaries show only a quiet tool count (e.g. 8 tools) — no token estimate, no PASS badge. Over budget (amber/red), the full treatment appears: count + token estimate + verdict badge/label, plus the existing routing into the trim/refine loop. The trim/refine loop itself keeps its full live count/token/verdict readout, since budget is the point there. --json output is unchanged and unconditionally includes toolCount, estimatedTokens, and verdict — only the human rendering goes quiet (budgetLine in ui.ts).

The interactive toolset menu always shows all four options with the cursor on the first one — no verdict-driven preselection. The per-option counts (quiet on green) carry the information; the only over-budget difference is that Main resources gets a green (recommended) tag and Everything shows its over-budget line inline (still routing into refine if chosen). --yes/--preset and JSON behavior are unchanged.

Changes Made

  • Extend addMcpCommand in packages/cli/cli/src/cli.ts with init, list, tools, generate, dev subcommands
  • Add prototype modules under packages/cli/cli/src/commands/mcp/prototype/
  • Add unreleased changelog entry (type: feat)
  • Vitest unit tests for spec summarizing, selector resolution, verdict logic, preset inference (tagged happy path, untagged path fallback, version-prefix stripping, schema-centrality rescue, flat-score gate, deprecated/x-internal exclusion, budget trimming), and AI-curated heuristics (27 tests)

Testing

  • Unit tests added/updated — pnpm vitest run src/commands/mcp/prototype/__test__ → 27 passed
  • Manual testing completed — built the dev CLI (node build.dev.mjs) and ran the full flow against a scratch petstore workspace (19 endpoints). Full transcript below.
End-to-end demo transcript (petstore workspace)
# fern mcp prototype — end-to-end demo transcript
# workspace: petstore OpenAPI 3.0 spec (19 endpoints), scratch fern workspace

$ fern mcp init --yes

┌ Create an MCP server
│  Swagger Petstore - OpenAPI 3.0: 19 endpoints · ~4k tokens (est.) if all become tools
│  Recommended budget: 40 tools · 60k tokens (adjustable later)


└ Wrote group "mcp" to /home/ubuntu/mcp-demo/fern/generators.yml

groups:
  mcp:
    generators:
      - name: fernapi/fern-mcp-server
        version: 0.1.0
        output:
          location: local-file-system
          path: ../../generated/mcp
        config:
          server-name: swagger-petstore-openapi-3-0-mcp
          tools:
            include:
              - method: GET

Verdict: 8 tools · 2k tokens — ✓ within budget

Next steps:
  fern generate --group mcp     # build it
  fern mcp dev --group mcp      # try it locally with an inspector

$ fern mcp init --name petstore-main --preset main-resources --group mcp-main --yes

┌ Create an MCP server
│  Swagger Petstore - OpenAPI 3.0: 19 endpoints · ~4k tokens (est.) if all become tools
│  Recommended budget: 40 tools · 60k tokens (adjustable later)


└ Wrote group "mcp-main" to /home/ubuntu/mcp-demo/fern/generators.yml

groups:
  mcp-main:
    generators:
      - name: fernapi/fern-mcp-server
        version: 0.1.0
        output:
          location: local-file-system
          path: ../../generated/mcp-main
        config:
          server-name: petstore-main
          tools:
            include:
              - tag: pet
              - tag: user
              - tag: store

Verdict: 19 tools · 4k tokens — ✓ within budget

Next steps:
  fern generate --group mcp-main     # build it
  fern mcp dev --group mcp-main      # try it locally with an inspector

$ fern mcp init --intent let agents look up pets and orders, never delete anything --group mcp-ai --yes

┌ Create an MCP server
│  Swagger Petstore - OpenAPI 3.0: 19 endpoints · ~4k tokens (est.) if all become tools
│  Recommended budget: 40 tools · 60k tokens (adjustable later)

✦ Fern Agent is proposing a ruleset… (stubbed locally in this prototype)

✦ Proposed ruleset (exclusions first — that's the part worth reviewing):
  exclude:
    - { method: DELETE } — "never delete anything"
  include:
    - { tag: pet, method: GET } — read-only pet

└ Wrote group "mcp-ai" to /home/ubuntu/mcp-demo/fern/generators.yml

groups:
  mcp-ai:
    generators:
      - name: fernapi/fern-mcp-server
        version: 0.1.0
        output:
          location: local-file-system
          path: ../../generated/mcp-ai
        config:
          server-name: swagger-petstore-openapi-3-0-mcp
          tools:
            intent: let agents look up pets and orders, never delete anything
            include:
              - tag: pet
                method: GET
            exclude:
              - method: DELETE

Verdict: 3 tools · 730 tokens — ✓ within budget

Next steps:
  fern generate --group mcp-ai     # build it
  fern mcp dev --group mcp-ai      # try it locally with an inspector

$ fern mcp list

mcp (api)
  server-name: swagger-petstore-openapi-3-0-mcp
  output:      ../../generated/mcp
  tools:       8 tools · 2k tokens — ✓ within budget

mcp-main (api)
  server-name: petstore-main
  output:      ../../generated/mcp-main
  tools:       19 tools · 4k tokens — ✓ within budget

mcp-ai (api)
  server-name: swagger-petstore-openapi-3-0-mcp
  output:      ../../generated/mcp-ai
  tools:       3 tools · 730 tokens — ✓ within budget


$ fern mcp tools

Tool surface for mcp — swagger-petstore-openapi-3-0-mcp

  find_pets_by_status  GET /pet/findByStatus · ~260 tokens
  find_pets_by_tags  GET /pet/findByTags · ~247 tokens
  get_pet_by_id  GET /pet/{petId} · ~223 tokens
  get_inventory  GET /store/inventory · ~174 tokens
  get_order_by_id  GET /store/order/{orderId} · ~246 tokens
  login_user  GET /user/login · ~283 tokens
  logout_user  GET /user/logout · ~125 tokens
  get_user_by_name  GET /user/{username} · ~219 tokens

8 tools · 2k tokens — ✓ within budget

$ fern mcp tools --group mcp-ai --json
{
  "group": "mcp-ai",
  "preset": null,
  "serverName": "swagger-petstore-openapi-3-0-mcp",
  "tools": [
    {
      "name": "find_pets_by_status",
      "method": "GET",
      "path": "/pet/findByStatus",
      "estimatedTokens": 260
    },
    {
      "name": "find_pets_by_tags",
      "method": "GET",
      "path": "/pet/findByTags",
      "estimatedTokens": 247
    },
    {
      "name": "get_pet_by_id",
      "method": "GET",
      "path": "/pet/{petId}",
      "estimatedTokens": 223
    }
  ],
  "toolCount": 3,
  "estimatedTokens": 730,
  "verdict": "green"
}

$ fern mcp generate

[mcp] Generating swagger-petstore-openapi-3-0-mcp (group: mcp)
[mcp] NOTE: this is a stubbed prototype — no code is actually generated.
[mcp] Using generator fernapi/fern-mcp-server@0.1.0
[mcp] Parsed spec: 19 endpoints
[mcp] Resolved tool surface: 8 tools · 2k tokens — ✓ within budget
[mcp] Building tool schemas… 8/8
[mcp] Wrote lockfile: /home/ubuntu/mcp-demo/fern/tools.lock
[mcp] Done. 8 tools locked for swagger-petstore-openapi-3-0-mcp.

Next: fern mcp dev --group mcp

$ fern mcp dev

Local dev for swagger-petstore-openapi-3-0-mcp (group: mcp)
This prototype does not start a real MCP runtime — here's how you would:

1. Generate the server (stubbed in this prototype):
     fern mcp generate --group mcp

2. Inspect it with the MCP Inspector:
     npx @modelcontextprotocol/inspector node ../../generated/mcp/server.js

3. Or wire it into your agent (e.g. Claude Desktop / Cursor) with:
     { "command": "node", "args": ["../../generated/mcp/server.js"] }

Tool surface: run `fern mcp tools --group mcp` to review before shipping.

$ cat fern/generators.yml
# yaml-language-server: $schema=https://schema.buildwithfern.dev/generators-yml.json
api:
  specs:
    - openapi: openapi/petstore.yml
groups:
  ts-sdk:
    generators:
      - name: fernapi/fern-typescript-sdk
        version: 3.31.3
        output:
          location: local-file-system
          path: ../generated/ts
  mcp:
    generators:
      - name: fernapi/fern-mcp-server
        version: 0.1.0
        output:
          location: local-file-system
          path: ../../generated/mcp
        config:
          server-name: swagger-petstore-openapi-3-0-mcp
          tools:
            include:
              - method: GET
  mcp-main:
    generators:
      - name: fernapi/fern-mcp-server
        version: 0.1.0
        output:
          location: local-file-system
          path: ../../generated/mcp-main
        config:
          server-name: petstore-main
          tools:
            include:
              - tag: pet
              - tag: user
              - tag: store
  mcp-ai:
    generators:
      - name: fernapi/fern-mcp-server
        version: 0.1.0
        output:
          location: local-file-system
          path: ../../generated/mcp-ai
        config:
          server-name: swagger-petstore-openapi-3-0-mcp
          tools:
            intent: let agents look up pets and orders, never delete anything
            include:
              - tag: pet
                method: GET
            exclude:
              - method: DELETE

$ cat fern/tools.lock
version: 1
group: mcp
server-name: swagger-petstore-openapi-3-0-mcp
generator: fernapi/fern-mcp-server@0.1.0
schema-hash: sha256:57e4e85511bb1275
generated-at: '2026-08-26T19:31:21.685Z'
tool-count: 8
estimated-tokens: 1777
tools:
  - name: find_pets_by_status
    endpoint: GET /pet/findByStatus
    estimated-tokens: 260
  - name: find_pets_by_tags
    endpoint: GET /pet/findByTags
    estimated-tokens: 247
  - name: get_pet_by_id
    endpoint: GET /pet/{petId}
    estimated-tokens: 223
  - name: get_inventory
    endpoint: GET /store/inventory
    estimated-tokens: 174
  - name: get_order_by_id
    endpoint: GET /store/order/{orderId}
    estimated-tokens: 246
  - name: login_user
    endpoint: GET /user/login
    estimated-tokens: 283
  - name: logout_user
    endpoint: GET /user/logout
    estimated-tokens: 125
  - name: get_user_by_name
    endpoint: GET /user/{username}
    estimated-tokens: 219

Notes:

  • pnpm turbo run compile --filter @fern-api/cli passes for the CLI package; a preexisting, unrelated compile error exists on main in @fern-api/docs-validator (valid-changelog-slug.test.ts TS2322), so the dev CLI was bundled via node build.dev.mjs directly.
  • The fernapi/fern-mcp-server@0.1.0 generator referenced in generators.yml does not exist — fern generate on the written config is out of scope for this prototype; fern mcp generate provides the stubbed path.

Link to Devin session: https://app.devin.ai/sessions/9c59da7519e74af1a4a7a78d0e8e2abe
Open in Devin Desktop: https://app.devin.ai/desktop/session/9c59da7519e74af1a4a7a78d0e8e2abe?variant=devin
Requested by: @matlegault


Open in Devin Review

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration

Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@nitpickybot nitpickybot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the changes — everything looks good. No issues found.


To request another review, comment /ai-review on this pull request.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

View 1 additional finding in Devin Review. (Configure)

Open in Devin Review


const configurationAsRecord: Record<string, unknown> = isRecord(parsedFile) ? { ...parsedFile } : {};
const groups = isRecord(configurationAsRecord.groups) ? { ...configurationAsRecord.groups } : {};
groups[groupName] = { generators: [generatorEntry] };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Writing an MCP group deletes other generators in it

writeMcpGroupToGeneratorsYml overwrites the whole target group with a single MCP entry (groups[groupName] = { generators: [generatorEntry] }). Running fern mcp init --group <existing-group> silently deletes every other generator in that group from generators.yml.

Prompt for agents
In writeMcpGroupToGeneratorsYml (packages/cli/cli/src/commands/mcp/prototype/mcpGeneratorsYml.ts), the line groups[groupName] = { generators: [generatorEntry] } replaces the entire group, discarding any pre-existing generators in that group. This causes silent data loss when the target group already contains other generators (for example an SDK generator, or when a user passes --group pointing at an existing group). Consider merging into the existing group: read the existing group's generators array, replace only the existing fernapi/fern-mcp-server entry (if any) or append the MCP entry, and preserve unrelated generators. The comment above already states the intent to preserve unrelated keys verbatim, so the same principle should apply to sibling generators within the group.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c467bde: writeMcpGroupToGeneratorsYml now merges into the existing group — it preserves unrelated group properties and sibling generator entries, replacing only the existing fernapi/fern-mcp-server entry (or appending if none). Verified by adding a fernapi/fern-typescript-sdk entry to the mcp group and re-running fern mcp init --yes: the SDK entry survives.

Comment on lines +62 to +79
if (refine) {
toolsConfig = await runTrimLoop({
cliContext,
endpoints: spec.endpoints,
initialConfig: toolsConfig
});
await cliContext.runTaskForWorkspace(workspaceSpec.workspace, async (context) => {
await writeMcpGroupToGeneratorsYml({
absolutePathToWorkspace,
context,
groupName: group,
serverName: found.config["server-name"],
toolsConfig,
presets: found.config.tools?.presets
});
});
cliContext.logger.info(chalk.green(`Updated group "${group}" in generators.yml.`));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Refining a preset overwrites the group default toolset

With --preset X --refine, toolsConfig becomes preset X's config (toolsMcp.ts:58) and is then written back as the group's top-level tools. The group's default include/exclude is clobbered while preset X stays unchanged, the reverse of the intended refine.

Prompt for agents
In toolsMcp (packages/cli/cli/src/commands/mcp/prototype/toolsMcp.ts), when --preset and --refine are used together, the refined config is written to the group's top-level tools block instead of back into tools.presets[preset]. This overwrites the group's default toolset and leaves the named preset unchanged. When preset != null, the refined toolsConfig should be written into the presets map under that preset name (keeping the group's top-level tools intact), rather than passed as the top-level toolsConfig argument to writeMcpGroupToGeneratorsYml.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c467bde: refining with --preset <name> now writes the refined config back into tools.presets[<name>] and leaves the group-level default tools block untouched. Verified end-to-end: refined pets-only preset gained exclude: [{method: POST}] while group-level include: [{method: GET}] was unchanged.

Comment on lines +150 to +152
if (args.intent != null && args.preset == null) {
presetKey = "ai-curated";
toolsConfig = await promptForAiCuratedConfig({ cliContext, endpoints, initialIntent: args.intent });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 --json --intent emits non-JSON before the summary

With --json --intent, initMcp still takes the AI-curated branch and calls promptForAiCuratedConfig, which unconditionally logs the proposed ruleset. Those human-readable lines print before the JSON summary on the same stream, so the --json output no longer parses.

Prompt for agents
In initMcp (packages/cli/cli/src/commands/mcp/prototype/initMcp.ts), the AI-curated branch (args.intent set, args.preset null) calls promptForAiCuratedConfig, which prints the spinner line and the proposed ruleset via cliContext.logger.info. When json mode is active this pollutes the machine-readable output because the JSON summary is emitted on the same logger stream. Either suppress the ruleset logging when json is true (pass a flag / guard the logger.info calls in promptForAiCuratedConfig), or compute the AI proposal without logging in json mode.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c467bde: in --json mode the AI helper skips the spinner and proposal logging (and the artificial delay) while still computing the ruleset and persisting intent:. Verified fern mcp init --json --intent "..." output now parses as valid JSON.

matlegault and others added 12 commits August 26, 2026 19:42
…oup merge, json output)

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…es, and tool tables

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
… prototype

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
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