MCP servers that work well in Streamliner

Reviewing a server; what to ask its owner to change

Raw Markdown the file as the skill ships it All pages ↓

For whoever writes or owns the MCP servers a streamline uses. Any standard MCP server works; these rules make it fast, safe with secrets and honest about what happened. Use this list to review a server, and to tell its owner what to change; never to work around a server's own rules.

Contents

  • Rules, one by one
  • Result shape
  • Errors
  • Checklist

Rules, one by one

  1. The API holds the rules. Price floors, limits, who may see what: enforced in the API for every call, as the signed-in person. Assume the caller is a model that can be wrong or manipulated.
  2. One job, one handle. The first tool that creates the job's object returns its id (cartId, poId) with chat.set so the stream keeps it; later tools take it as an argument. MCP 2026-07-28 is stateless.
  3. Annotate every tool: readOnlyHint, destructiveHint, idempotentHint. "WRITE" in a description is not an annotation. Without annotations every call needs a tap (personal) or a hand-written policy.
  4. **No secret in any inputSchema.** SSN, date of birth, license, PIN, password, income for a credit check: return a sealed form (MCP form-mode elicitation naming a configured form, or the sealedForm directive). Cards not present: URL-mode elicitation to the processor's hosted page. Cards present: a terminal tool.
  5. Confirm-gated writes: a read-only preview tool returning the real current state (the cart), the commit tool marked destructiveHint: true, and still validated and idempotent in the API.
  6. **direct tools** take fully determined arguments, do one small thing, are safe to run twice.
  7. Completion: the completing tool returns the final identifiers and complete: true (or is listed in completion.tools); chat.status: "waiting" while blocked on someone else; never report success not achieved.
  8. Sign-in errors are HTTP: missing, expired or revoked credential → 401 with WWW-Authenticate (OAuth servers: resource_metadata); signed in but not allowed → 403. Never "please log in" as text. An ended session is a 401, never a business code (if a server answers {ok:false, error:{code:"SESSION_ENDED"}} with 200, a personal streamline can list that code in reauthOn as a stopgap).
  9. Act as the person, never a service account; allow calls from phones (no IP-pinning to Streamliner).
  10. Scans and files are plain arguments. A scan arrives as a string (code, sku): accept GS1 element strings, validate in the API, answer an unknown code as a business outcome. An attachment reaches a tool only as an upload reference the app swaps for a 15-minute signed link (attachments.toModel: false); fetch it once, never store it. Personal streamlines can take the file in the call: mark the property "type": "string", "contentEncoding": "base64", "contentMediaType": "text/csv"; the model passes the att_… id, and after the gates the app sends the base64 of the file as picked (text files unredacted, images as processed), the model keeping only the id. At most about 6 MB of files per call. List such tools only to a client whose capabilities include "com.manolab.streamline/files": {}; keep another way in for every other client.
  11. Idempotency: honor Idempotency-Key on every write; the same key returns the same result with no second effect.

Result shape

{ "content": [{ "type": "text", "text": "Added the 1.52 ct solitaire (R-20417) at $4,850." }],
  "structuredContent": { "cartId": "C-1029", "item": { "sku": "R-20417", "price": 4850 } },
  "_meta": { "com.manolab.streamline/ui": { "card": { "template": "item.card", "data": "$.item" },
                                            "overview": { "refresh": true }, "chat": { "set": { "cartId": "C-1029" } } } } }
  • content: one or two factual sentences for the model (paid for on every later turn).
  • structuredContent: the data templates draw; keep its shape stable (it is a contract with the templates).
  • Directives (the model never sees _meta): card / cards ({template, data}), overview ({refresh: true} or {data}), chat.set (≤ 20 string/number keys), chat.status (open \| waiting \| done), sealedForm, sealedView, suggestions (≤ 3 chips), notice ({text, tone}), complete: true. Unknown keys are ignored.
  • Never send HTML for Streamliner (MCP Apps ui:// resources are ignored in the app).

Errors

  • Business outcomes (not found, refused by a rule, bad or missing argument): a normal result { "ok": false, "error": { "code": "NOT_FOUND", "message": "No such state.", "field": "stateId" } }, and { "ok": true, "data": … } on success. The app shows a calm outcome card and never counts it as a write.
  • Failures (the server or its upstream broke): isError: true with one plain sentence.
  • No stack traces, internal ids or anything sensitive in either.

Checklist

  • [ ] Rules enforced in the API, as the user; no service account
  • [ ] A handle per job, set with chat.set
  • [ ] Short content, full structuredContent, a named template
  • [ ] No secret in any inputSchema
  • [ ] A read-only preview for every confirm-gated tool
  • [ ] direct only on small, determined, repeatable tools
  • [ ] A completing tool that returns the final status
  • [ ] Annotations on every tool
  • [ ] 401/403 for auth; an ended session is a 401
  • [ ] {ok, data} / {ok:false, error:{code, message, field}} for business answers
  • [ ] Idempotency-Key honored on every write

Narrow per person, and name tools by area (2026-10-12)

  • **Answer tools/list per token** with only what this person may call, and send notifications/tools/list_changed when that changes. The app lists tools with the person's own credential, so they never see the rest.
  • If you list everything, refuse a call the person may not make clearly: HTTP 403 (without insufficient_scope, which means "sign in again"), or { "ok": false, "error": { "code": "FORBIDDEN", "message": "…" } }. The app then stops offering that tool to that person for a day. Never use FORBIDDEN for a rule about one record (another store's offer): give that its own code, or the tool vanishes for someone who may use it.
  • Name tools by area (customer_search, customer_create, inventory_item_get) so a streamline can admit an area with a pattern (customer_*) and new tools land where they belong.