Skip to content

feat(mcp): expose resources and prompts alongside the tools - #358

Merged
BryanFRD merged 1 commit into
mainfrom
feat/resources-and-prompts
Sep 20, 2026
Merged

BryanFRD merged 1 commit into
mainfrom
feat/resources-and-prompts

Conversation

@BryanFRD

Copy link
Copy Markdown
Contributor

Closes #249

The six servers exposed tools and nothing else, so anything a client wanted as ambient context had to be fetched by a tool call that spends a turn and lands as a blob in the transcript.

Resources

Server URI Enumerable
@ferrlabs/mcp ferrlabs://org/{slug}/overview yes, from list_orgs
@ferrlabs/mcp ferrlabs://org/{slug}/usage yes, from list_orgs
@ferrtrack/mcp ferrtrack://project/{slug}/issues yes, from list_projects
@ferrvault/mcp ferrvault://org/{org}/project/{project}/vault/{id} no

The vault template lists empty on purpose. Enumerating vaults means walking every org and every project on each resources/list, which is a lot of requests for a browse nobody asked for, so a vault is addressed directly by URI.

Vault resources return metadata only. Name, description, secret count, timestamps. Values stay behind get_secret, its reveal=true argument and the server-side audit log. No resource path can reach one.

Prompts

triage_backlog on FerrTrack walks a project's open issues and proposes one decision per issue, grouped by action so the same change can be applied in a batch. It ends by telling the assistant not to call update_issue yet, because a triage pass that mutates before anyone has read it is not a triage pass.

review_run on FerrFleet reads a run and reports what the agent was asked to do, what it actually did, what it changed outside its own workspace, and where it guessed. It tells the assistant to call get_run before get_run_transcript, since a long transcript is capped and arrives truncated.

Against the acceptance criteria

No second copy of the URL building. Resource handlers call the same functions the tools now call. Five fetches were extracted rather than duplicated: fetchOrgOverview, fetchOrgUsage, fetchOrgs, fetchProjects, fetchProjectIssues, fetchVaultDetails. Each tool body shrank to a call plus its result formatting.

Capabilities are declared. Not by hand: the SDK registers them when a resource or prompt is registered, so a server exposing neither advertises neither. Confirmed per server rather than assumed.

resources/list and prompts/list return a non-empty set. Asserted in pnpm smoke, which boots all five binaries and now checks resources/templates/list and prompts/list against a declared expectation:

[PASS] ferrlabs: resources/templates/list — 2 templates advertised
[PASS] ferrvault: resources/templates/list — 1 templates advertised
[PASS] ferrtrack: resources/templates/list — 1 templates advertised
[PASS] ferrtrack: prompts/list — 1 prompts advertised
[PASS] ferrfleet: prompts/list — 1 prompts advertised

Smoke: 17/17 OK across 5 servers

The README documents what each server exposes, in a new section, including why the vault template does not enumerate and that it never carries a value.

One thing worth knowing

ResourceTemplate is now re-exported from @ferrlabs/mcp-core, and the sub-MCPs import it from there rather than from the SDK directly.

They have to. The sub-packages do not declare @modelcontextprotocol/sdk themselves, so importing it resolved through hoisting to a different type identity than the one McpServer carries, and TypeScript silently picked the string overload of registerResource. The symptom was Property 'id' does not exist on type 'RequestHandlerExtra', which reads like a callback signature mistake and is not one. Routing the type through mcp-core, the way McpServer already was, makes the identity single.

Verification

pnpm build, pnpm typecheck, pnpm format:check, pnpm test (117 passed, 2 skipped) and pnpm smoke (17/17) all pass.

The existing unit tests cover the extracted fetch functions through the tools that call them, so the extraction is guarded against a wrong path. The resource handlers themselves are covered by smoke rather than by unit tests: asserting them in isolation would mean mocking the SDK's registration, which tests the mock.

@BryanFRD BryanFRD added P3 Low priority / someday feature New feature or capability labels Sep 20, 2026

@ferrfleet ferrfleet 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.

The extraction is clean: each fetch has one definition, the tools thinned to a call plus formatting, and the smoke assertions check the thing that actually regresses (an advertised template disappearing) rather than the registration call.

One blocking finding on the issues resource: toToolText appends a plaintext truncation notice, which breaks the application/json mime type it is served under. Details inline. One nit on a duplicated /orgs fetch.

Two things I did not verify, since neither is visible in the diff: that the issues endpoint defaults to status=open (the resource relies on it, the tool only documents it), and that the SDK decodes template variables before the handler re-encodes them with encodeURIComponent. Both are fine for slug-shaped values.

async (uri, { slug }) => {
const issues = await fetchProjectIssues(String(slug));
return {
contents: [{ uri: uri.href, mimeType: 'application/json', text: toToolText(issues) }],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Blocking: toToolText can emit text that is not JSON, but this content block declares mimeType: 'application/json'.

Over the 100 KB ceiling it returns a byte-sliced prefix plus \n\n[truncated: ...]. For a tool that is fine, the model reads prose. For a resource a client parses by mime type, it is a JSON.parse failure on a resource that claims to be JSON, and the cut happens mid-object so there is no recovery. body is capped at 100 000 chars per issue and the API returns 50 by default, so one project with a few long issues gets there.

The org and vault resources use the same pattern but their payloads are fixed-size, so this one is the one that actually trips.

Two ways out. Serialising with JSON.stringify(issues) and dropping the cap keeps the output valid but unbounded (worst case a few MB over stdio). Keeping a cap and dropping whole array elements until the serialised form fits keeps both properties, at the cost of importing maxToolBytes here and deciding what to do about the silence — a client has no way to tell a truncated list from a short one, so the count belongs in the payload, e.g. { issues, truncated: true, total }.

I would take the second: a resource is attached once and read as fact, and a short list that looks complete is the failure mode worth spending a wrapper object on. That changes the shape from a bare array, so the resource description should say so.

import { listOrgSlugs } from '../tools/orgs.js';

async function listOrgResources(scheme: string, suffix: string) {
const slugs = await listOrgSlugs();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: both templates register this as their list callback, so every resources/list hits /orgs twice for the same data. Not wrong, just a duplicated round trip on a call clients make at connect time and again on refresh. A short-lived memo around listOrgSlugs, or one list callback whose result both templates derive from, removes it.

@BryanFRD
BryanFRD merged commit 326baaf into main Sep 20, 2026
16 checks passed
@BryanFRD
BryanFRD deleted the feat/resources-and-prompts branch September 20, 2026 15:32
ferrflow Bot added a commit that referenced this pull request Sep 20, 2026
## [8.2.0] - 2026-09-20

### Features

- feat(mcp): expose resources and prompts alongside the tools (#358)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or capability P3 Low priority / someday

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(mcp): expose MCP resources and prompts, not just tools

1 participant