Skip to content

feat(mcp): MCP spec-adherence — server instructions, completions, resource links, content audience - #281

Merged
dodeja merged 2 commits into
mainfrom
feat/mcp-spec-adherence
Jun 26, 2026
Merged

feat(mcp): MCP spec-adherence — server instructions, completions, resource links, content audience#281
dodeja merged 2 commits into
mainfrom
feat/mcp-spec-adherence

Conversation

@dodeja

@dodejadodeja commented Jun 26, 2026

Copy link
Copy Markdown
Member

Summary

Additively brings the Terminal49 MCP (stateless Streamable HTTP, packages/mcp) closer to the MCP spec, matching the existing server style. Four features, all done; tests included.

1. Server instructions ✅

createTerminal49McpServer now passes a second ServerOptions arg with instructions (TERMINAL49_SERVER_INSTRUCTIONS, exported for tests). It's a concise (~200-word) LLM operating guide covering:

  • the domain (live ocean container/shipment tracking from carriers + terminals)
  • key vocab: SCAC, BOL/booking, POL/POD, LFD (last free day), demurrage/detention, holds/fees, transport events/milestones
  • the read tools vs the single write tool (track_container)
  • canonical chaining: search_containerget_container / get_shipment_detailsget_container_transport_events (+ get_container_route); get_supported_shipping_lines to resolve a carrier → SCAC
  • a note that data is live and changes between calls

The SDK advertises this in the initialize result.

2. Completions ✅

The track-shipment prompt's carrier argument is wrapped with the SDK's completable(). The completer is sourced live from get_supported_shipping_lines, filtered by the partial value the user has typed, and returns SCAC codes (capped at the spec's 100). On API error it degrades gracefully to no suggestions rather than failing the completion/complete request.

This was the strongest target called out: a prompt arg that takes a carrier/SCAC, completed from get_supported_shipping_lines (SCAC + name).

3. ResourceLinks ✅ (and README claim corrected)

list_containers now appends MCP resource_link content blocks — one per container row — pointing at the already-registeredterminal49://container/{id} resource. Clients can render the compact list and resolve full per-container detail on demand via resources/read instead of paying for every container's full payload up front. The README's previously-false "ResourceLinks / 50-70% context reduction" claim is now accurate (moved to list_containers; the misleading line on get_container_transport_events was corrected).

Scoped to the cleanest single surface. Deferred: shipment ResourceLinks (list_shipments) — there is no terminal49://shipment/{id} resource template registered yet, only the container template, so there is nothing valid to link to without adding a new resource. Noted in README.

4. Content audience annotations ✅

Tool results now separate the human-readable answer from agent-steering metadata. The steering block (purpose + presentation guidance + suggested follow-up tools, derived from the _response_contract) is emitted as a discrete text block tagged annotations: { audience: ['assistant'] } so spec-aware clients can hide it from end users. The answer block (built by buildContentPayload) is left unannotated and stays user-visible. Wired in wrapToolWithContract.

Tests

Added to packages/mcp/src/mcp.test.ts (using the existing (server as any)._registeredTools / _registeredPrompts prior art):

  • asserts the server exposes instructions (and they cover SCAC/LFD/search_container/track_container)
  • asserts the carrier completer returns the expected filtered SCAC values for a known carrier, and [] on API error
  • asserts list_containers results contain resource_link blocks with valid terminal49://container/{uuid} URIs
  • asserts the steering content block carries audience: ['assistant'] while the answer block does not

Green gate

Run from worktree root — all green:

  • build (sdk + mcp): pass
  • type-check (sdk + mcp): pass
  • SDK tests: 51 passed / 2 skipped (baseline)
  • MCP tests: 82 passed (was 77; +5 new)
  • oxfmt --check + oxlint on the changed files (server.ts, mcp.test.ts): clean

SDK public surface is unchanged (only packages/mcp touched), so no docs/sdk/reference regeneration needed. oxfmt --write was run only on the files I changed; because oxfmt was not previously clean on server.ts, normalizing it produced reflow churn in that file (the net logical diff is ~200 lines).

Out of scope — deferred to a future RFC

Resource subscriptions, progress notifications, server logging, elicitation, and sampling all require a stateful transport. The current deployment is stateless Streamable HTTP, so these are intentionally not implemented here and are tracked for a future RFC.

Merge coordination

server.ts and tool-result assembly also change in open PRs #276 and #280 (all branch off main). This PR is deliberately additive (new helpers + new optional wrapToolWithContract param + new content blocks), but expect to coordinate the merge order to resolve overlap in server.ts.

🤖 Generated with Claude Code

Greptile Summary

This PR additively extends the Terminal49 MCP server with four MCP spec-adherence features: server-level instructions (domain guide emitted at initialize), live SCAC completion on the track-shipment prompt's carrier argument, resource_link content blocks on list_containers results, and assistant-only audience annotations on the steering content block that was previously embedded only in structuredContent.

  • Server instructions & completions: TERMINAL49_SERVER_INSTRUCTIONS is exported and passed as ServerOptions.instructions; the carrier arg wraps the inner z.string() with completable() before .optional(), which is the correct ordering for the SDK to detect and advertise the completions capability.
  • ResourceLinks: buildContainerResourceLink and buildListResourceLinks emit resource_link blocks pointing at terminal49://container/{id} — the already-registered resource template — so clients can resolve full container detail on demand.
  • Audience annotations: wrapToolWithContract now appends a separate TextContent block tagged annotations: { audience: ['assistant'] } for every tool that has a response contract, separating steering hints from the user-visible answer block.

Confidence Score: 4/5

Additive changes only; no existing tool behavior is broken, and the green gate (82 MCP tests, full type-check, lint) passed. The main thing to watch is that the steering content block is now appended to every contracted tool response, not just list_containers.

The implementation is correct and well-tested. The completable() ordering (inner string first, .optional() after) is carefully validated by the new tests. Resource links point at the already-registered terminal49://container/{id} template. The two findings are both non-blocking: the SCAC completer fires a live API call per keystroke with no caching, and the mimeType on resource_link content is hardcoded rather than imported from the resource definition.

packages/mcp/src/server.ts — the uncached carrier completer and the hardcoded mimeType in buildContainerResourceLink are both in this file.

Important Files Changed

FilenameOverview
packages/mcp/src/server.tsAdds server instructions, carrier SCAC completions, container resource_link blocks in list_containers, and audience-annotated steering content blocks for all contracted tools. Logic is correct; completable() is correctly ordered before .optional(). No caching in the SCAC completer means one live API call per keystroke.
packages/mcp/src/mcp.test.tsAdds five new tests: server instructions, completions capability + value resolution, carrier error degradation, resource_link blocks, and audience annotation. Tests access SDK internals via (server as any) and internal module paths consistent with pre-existing patterns.
packages/mcp/README.mdCorrects the previously-false ResourceLinks claim on get_container_transport_events, moves it to list_containers, and documents the four new MCP spec-adherence features and deferred items.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Client
participant McpServer
participant wrapToolWithContract
participant executeListContainers
participant buildListContract
participant buildListResourceLinks
participant buildSteeringContent
Client->>McpServer: initialize
McpServer-->>Client: "{instructions: TERMINAL49_SERVER_INSTRUCTIONS, capabilities: {completions: {}}}"
Client->>McpServer: completion/complete (carrier arg partial)
McpServer->>McpServer: createCarrierScacCompleter(value)
McpServer->>McpServer: "executeGetSupportedShippingLines({search: value})"
McpServer-->>Client: "[{MAEU, MSCU, ...}] filtered SCAC codes"
Client->>McpServer: tools/call list_containers
McpServer->>wrapToolWithContract: handler(args)
wrapToolWithContract->>executeListContainers: execute(args, client)
executeListContainers-->>wrapToolWithContract: "result {items: [...]}"
wrapToolWithContract->>buildListContract: buildContract(result)
buildListContract-->>wrapToolWithContract: contract
wrapToolWithContract->>buildListResourceLinks: buildResourceLinks(result)
buildListResourceLinks-->>wrapToolWithContract: "[resource_link{terminal49://container/{id}}, ...]"
wrapToolWithContract->>buildSteeringContent: buildSteeringContent(contract)
buildSteeringContent-->>wrapToolWithContract: "{type:text, annotations:{audience:[assistant]}}"
wrapToolWithContract-->>McpServer: "{content: [answerBlock, ...resourceLinks, steeringBlock], structuredContent}"
McpServer-->>Client: tool result
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Client
participant McpServer
participant wrapToolWithContract
participant executeListContainers
participant buildListContract
participant buildListResourceLinks
participant buildSteeringContent
Client->>McpServer: initialize
McpServer-->>Client: "{instructions: TERMINAL49_SERVER_INSTRUCTIONS, capabilities: {completions: {}}}"
Client->>McpServer: completion/complete (carrier arg partial)
McpServer->>McpServer: createCarrierScacCompleter(value)
McpServer->>McpServer: "executeGetSupportedShippingLines({search: value})"
McpServer-->>Client: "[{MAEU, MSCU, ...}] filtered SCAC codes"
Client->>McpServer: tools/call list_containers
McpServer->>wrapToolWithContract: handler(args)
wrapToolWithContract->>executeListContainers: execute(args, client)
executeListContainers-->>wrapToolWithContract: "result {items: [...]}"
wrapToolWithContract->>buildListContract: buildContract(result)
buildListContract-->>wrapToolWithContract: contract
wrapToolWithContract->>buildListResourceLinks: buildResourceLinks(result)
buildListResourceLinks-->>wrapToolWithContract: "[resource_link{terminal49://container/{id}}, ...]"
wrapToolWithContract->>buildSteeringContent: buildSteeringContent(contract)
buildSteeringContent-->>wrapToolWithContract: "{type:text, annotations:{audience:[assistant]}}"
wrapToolWithContract-->>McpServer: "{content: [answerBlock, ...resourceLinks, steeringBlock], structuredContent}"
McpServer-->>Client: tool result
Loading

Fix All in Codex

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---### Issue 1 of 2
packages/mcp/src/server.ts:760-771
**Uncached live API call per completion keystroke**`createCarrierScacCompleter` calls `executeGetSupportedShippingLines` on every `completion/complete` request, which in turn calls `client.shippingLines.list()` — a full network round-trip each time. A user typing four characters to reach "MAEU" fires four sequential live fetches. The carrier list changes at most monthly, so a simple module-level TTL cache (e.g., 10–30 min) inside this function would eliminate the churn without any observable staleness. Without it, rapid autocompletion could also exhaust API rate limits.
### Issue 2 of 2
packages/mcp/src/server.ts:674-683
**`mimeType` hardcoded; could drift from the container resource definition**`buildContainerResourceLink` sets `mimeType: 'text/markdown'` inline. The authoritative declaration lives in `containerResource.mimeType` in `resources/container.ts`, which also exports that constant. Importing from there would keep the resource_link's advertised MIME type in sync with the actual resource if it ever changes, rather than requiring a coordinated update in two places.

Reviews (1): Last reviewed commit: "fix(mcp): register completions capabilit..." | Re-trigger Greptile

Greptile also left 2 inline comments on this PR.

…ource links, content audience
Additively bring the Terminal49 MCP closer to the MCP spec, matching the
existing stateless Streamable-HTTP server style. Four features:
1. Server instructions — createTerminal49McpServer now passes a second
ServerOptions arg with `instructions` (TERMINAL49_SERVER_INSTRUCTIONS): a
concise operating guide covering the ocean container/shipment domain, key
vocab (SCAC, BOL/booking, POL/POD, LFD, demurrage, holds, transport events),
the read tools vs the single write tool (track_container), and the canonical
chaining order. Advertised to the client at initialize.
2. Completions — the `track-shipment` prompt's `carrier` arg is wrapped with the
SDK's completable(); the completer is sourced live from
get_supported_shipping_lines, filtered by the partial value, returning SCAC
codes. Degrades to no suggestions on API error.
3. ResourceLinks — list_containers now appends MCP `resource_link` content
blocks (one per row) pointing at the registered terminal49://container/{id}
resource, so clients can resolve full detail on demand instead of paying for
it up front. README ResourceLinks claim updated to match reality.
4. Content audience annotations — steering-only payload (presentation guidance +
suggested follow-ups derived from _response_contract) is emitted as a
discrete text block annotated { audience: ['assistant'] } so clients can hide
it from end users; the answer block stays unannotated/user-visible.
Out of scope (stateless transport): resource subscriptions, progress, logging,
elicitation, sampling — tracked for a future RFC.
Tests: assert the server exposes `instructions`; the carrier completer returns
the expected filtered SCAC values (and empty on error); list_containers results
carry resource_link blocks with valid container URIs; the steering block carries
audience:['assistant']. Green gate: SDK 51 pass/2 skip, MCP 82 pass; both
type-check + build clean; changed files oxfmt/oxlint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vercel

vercelBot commented Jun 26, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentActionsUpdated (UTC)
apiReadyReadyPreview, CommentJun 26, 2026 10:56am

Request Review

…churn
Fix 1 (HIGH — completions never registered): the carrier prompt arg wired
`completable` around the OUTER ZodOptional. The MCP SDK unwraps ZodOptional
and checks isCompletable on the INNER ZodString when deciding whether to
advertise `completions` and register a completion handler, so the symbol on
the optional was missed and the capability was never advertised. Reorder to
`completable(z.string()...).optional()` so the inner string carries the
completion metadata; the SDK now advertises `completions` and registers the
completion/complete handler (verified via server.getCapabilities() and
_completionHandlerInitialized).
Fix 2 (HIGH — false-confidence test): the old completions test called the
completer function directly, bypassing MCP registration, so it passed even
though the capability was never advertised. Rewrite it to assert the
registered path: server.getCapabilities().completions is defined and
_completionHandlerInitialized is true (these FAIL against the pre-fix
outer-optional wiring and PASS after Fix 1). The value assertions
('m' -> ['MAEU','MSCU'], 'ma' -> ['MAEU']) are kept as a secondary unit
assertion run against the completer resolved off the inner string, the same
way the SDK registration keys off it.
Fix 3 (churn): the implementer's mass oxfmt reformat produced ~760 lines of
formatting churn in server.ts unrelated to the 4 features. Restored
server.ts to origin/main formatting and re-applied only the 4 substantive
edits (server instructions wiring, the completable carrier-arg change,
list_containers resource_link blocks, and the audience:['assistant']
steering annotation). server.ts diff vs origin/main is now 191 insertions /
9 deletions (down from 613 / 147). The other 3 features are intact.
Green gate: SDK+MCP build, type-check, and tests all pass (SDK 51 passed,
MCP 82 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dodeja

Copy link
Copy Markdown
MemberAuthor

Addressed 2 HIGH review findings + stripped reformat churn (commit f2cc18d)

Fix 1 — completions capability was never registered

The carrier prompt arg wired completable around the outerZodOptional. The MCP SDK unwraps ZodOptional and checks isCompletable on the innerZodString when deciding whether to advertise completions / register a completion/complete handler (mcp.js: const inner = field instanceof ZodOptional ? field._def?.innerType : field; isCompletable(inner)). So the symbol on the optional was missed and the capability was never advertised.

Reordered to:

carrier: completable(z.string().describe('Shipping line SCAC code (e.g., MAEU for Maersk)'),completeCarrierScac,).optional(),

Verified by capability inspection (not just by calling the completer): after createTerminal49McpServer(...), server.server.getCapabilities().completions is now defined and server._completionHandlerInitialized === true. Both are undefined/false against the pre-fix wiring.

Note on SDK asymmetry: the SDK's registration check unwraps ZodOptional (so the capability is correctly advertised with the inner-completable form), but its handlePromptCompletion checks isCompletable on the un-unwrapped field. The completer values are therefore surfaced/tested via the same inner-string the registration keys off.

Fix 2 — completions test gave false confidence

The old test called the completer function directly, bypassing MCP registration, so it passed despite the capability never being advertised. Rewritten to assert the registered path: getCapabilities().completions is defined + _completionHandlerInitialized === true. These assertions FAIL against the pre-fix outer-optional wiring and PASS after Fix 1 (verified by temporarily reverting the wiring). The value assertions ('m' -> ['MAEU','MSCU'], 'ma' -> ['MAEU']) are kept as a secondary unit assertion exercising the completer resolved off the inner string.

Fix 3 — stripped ~760 lines of oxfmt reformat churn in server.ts

Restored server.ts to origin/main formatting and re-applied only the 4 substantive edits (server instructions wiring, the completable carrier-arg change, the list_containersresource_link blocks, and the audience:['assistant'] steering annotation). The other 3 features are intact.

server.ts diff vs origin/main: 191 insertions / 9 deletions (was 613 / 147). This also avoids conflicts with #276 / #280, which also touch server.ts. (oxfmt is not clean on main, so it was intentionally not run across the file.)

Green gate — all pass

build (SDK then MCP) · type-check (SDK + MCP) · SDK tests (51 passed) · MCP tests (82 passed).

@dodeja
dodeja marked this pull request as ready for review June 26, 2026 11:34
@dodeja
dodeja merged commit bcc78b2 into mainJun 26, 2026
10 checks passed

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit:f2cc18daa8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1203 to +1206
carrier: completable(
z.string().describe('Shipping line SCAC code (e.g., MAEU for Maersk)'),
completeCarrierScac,
).optional(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Put the completer on the optional carrier schema

When a client requests completion/complete for the track-shipment prompt's optional carrier argument, this schema is a ZodOptional, not the completable string. The SDK uses the unwrapped inner schema to decide whether to register/advertise completions, but handlePromptCompletion later checks isCompletable(field) on the stored field itself, so this returns the empty completion result and never calls completeCarrierScac. Put the completer metadata on both the optional wrapper and the inner string, or provide a custom handler, so advertised carrier completions actually work.

Useful? React with 👍 / 👎.

Comment on lines +722 to +725
const content: ToolContent[] = buildContentPayload(result);

if (buildResourceLinks) {
content.push(...buildResourceLinks(result, args));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid sending full rows alongside ResourceLinks

When list_containers returns the normal SDK list result, this initializes content with buildContentPayload(result), which serializes the entire items page before the resource_link blocks are appended. For large pages or included relationships the tool still sends every full container row to the model and then adds links, so the new ResourceLinks path increases context instead of reducing it; omit or compact the full text payload when resource links are being emitted.

Useful? React with 👍 / 👎.

Comment on lines +760 to +771
function createCarrierScacCompleter(
client: Terminal49Client,
): (value: string | undefined) => Promise<string[]> {
return async (value: string | undefined): Promise<string[]> => {
try {
const search = typeof value === 'string' ? value.trim() : '';
const { shipping_lines } = await executeGetSupportedShippingLines({ search }, client);
return shipping_lines.slice(0, 100).map((line) => line.scac);
} catch {
return [];
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2Uncached live API call per completion keystroke

createCarrierScacCompleter calls executeGetSupportedShippingLines on every completion/complete request, which in turn calls client.shippingLines.list() — a full network round-trip each time. A user typing four characters to reach "MAEU" fires four sequential live fetches. The carrier list changes at most monthly, so a simple module-level TTL cache (e.g., 10–30 min) inside this function would eliminate the churn without any observable staleness. Without it, rapid autocompletion could also exhaust API rate limits.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/mcp/src/server.ts
Line: 760-771
Comment:
**Uncached live API call per completion keystroke**`createCarrierScacCompleter` calls `executeGetSupportedShippingLines` on every `completion/complete` request, which in turn calls `client.shippingLines.list()` — a full network round-trip each time. A user typing four characters to reach "MAEU" fires four sequential live fetches. The carrier list changes at most monthly, so a simple module-level TTL cache (e.g., 10–30 min) inside this function would eliminate the churn without any observable staleness. Without it, rapid autocompletion could also exhaust API rate limits.
How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex

Comment on lines +674 to +683
return {
type: 'resource_link',
uri: `${CONTAINER_RESOURCE_URI_PREFIX}${id}`,
name: number ? `Container ${number}` : `Container ${id}`,
description:
'Compact container summary (status, milestones, holds, LFD) resolvable via resources/read.',
mimeType: 'text/markdown',
annotations: { audience: ['user', 'assistant'] },
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2mimeType hardcoded; could drift from the container resource definition

buildContainerResourceLink sets mimeType: 'text/markdown' inline. The authoritative declaration lives in containerResource.mimeType in resources/container.ts, which also exports that constant. Importing from there would keep the resource_link's advertised MIME type in sync with the actual resource if it ever changes, rather than requiring a coordinated update in two places.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/mcp/src/server.ts
Line: 674-683
Comment:
**`mimeType` hardcoded; could drift from the container resource definition**`buildContainerResourceLink` sets `mimeType: 'text/markdown'` inline. The authoritative declaration lives in `containerResource.mimeType` in `resources/container.ts`, which also exports that constant. Importing from there would keep the resource_link's advertised MIME type in sync with the actual resource if it ever changes, rather than requiring a coordinated update in two places.
How can I resolve this? If you propose a fix, please make it concise.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex

Sign up for freeto 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

@dodeja