feat(mcp): expose resources and prompts alongside the tools - #358
Conversation
There was a problem hiding this comment.
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) }], |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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.
## [8.2.0] - 2026-09-20 ### Features - feat(mcp): expose resources and prompts alongside the tools (#358)
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
@ferrlabs/mcpferrlabs://org/{slug}/overviewlist_orgs@ferrlabs/mcpferrlabs://org/{slug}/usagelist_orgs@ferrtrack/mcpferrtrack://project/{slug}/issueslist_projects@ferrvault/mcpferrvault://org/{org}/project/{project}/vault/{id}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, itsreveal=trueargument and the server-side audit log. No resource path can reach one.Prompts
triage_backlogon 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 callupdate_issueyet, because a triage pass that mutates before anyone has read it is not a triage pass.review_runon 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 callget_runbeforeget_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/listandprompts/listreturn a non-empty set. Asserted inpnpm smoke, which boots all five binaries and now checksresources/templates/listandprompts/listagainst a declared expectation: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
ResourceTemplateis 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/sdkthemselves, so importing it resolved through hoisting to a different type identity than the oneMcpServercarries, and TypeScript silently picked the string overload ofregisterResource. The symptom wasProperty 'id' does not exist on type 'RequestHandlerExtra', which reads like a callback signature mistake and is not one. Routing the type throughmcp-core, the wayMcpServeralready was, makes the identity single.Verification
pnpm build,pnpm typecheck,pnpm format:check,pnpm test(117 passed, 2 skipped) andpnpm 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.