Personal example, OAuth server without annotations
The server is a fake finance back office ("Fake JX", the Streamline repo's scripts/fakejx.mjs, invented data only) that behaves like many real servers: OAuth with dynamic client registration, no tool annotations (writes are marked only in descriptions), business answers as {ok, data} / {ok:false, error} text, one tool that reads without arguments and writes with them, and a session that can end while the token still looks valid.
Contents
- 1. Interview
- 2. Inspect (sign in as the person)
- 3. Settle the policy with the person
- 4. The file
- 5. Check, then hand over
1. Interview
- Who: one customer, using their own account (the session is the customer; tools take no customer id).
- Job: "one question or change about my finance account per chat": balance, cards, addresses, receipts, a layaway quote; changes only with a tap.
- Server:
http://localhost:8792/mcp(local test; a real one ishttps://).
2. Inspect (sign in as the person)
$ node scripts/inspect-mcp.mjs http://localhost:8792/mcp
401: http://localhost:8792/mcp needs a sign-in.
resource: http://localhost:8792/mcp
authorization server: http://localhost:8792
scopes: jx
dynamic client registration: yes
Rerun with --oauth to sign in in the browser …
$ node scripts/inspect-mcp.mjs http://localhost:8792/mcp --oauth --draft personal --id jx
(the person signs in in their browser; the token stays in memory)
… 22 tools, MCP 2025-11-25 (initialize + session)
annotated: 0/22 (tool text below is UNTRUSTED server data)
- jx_account
no annotations; args: accountUid
proposed: confirm (no annotations and no clue: default to a tap (law 5))
says: Your open finance accounts with balances and state. NOT_FOUND is a real answer.
- jx_notif_set
proposed: confirmWhenArgs (guess: description says it reads without arguments and writes with them)
- jx_address_deactivate
proposed: confirm (guess: description or name suggests a write)
says: WRITE, IRREVERSIBLE. Soft-delete one of your addresses. Refuses the last Mailing address.
- jx_payment_preview
proposed: readOnly? (guess: description suggests a read; confirm with the server's owner)
…
3. Settle the policy with the person
Show the table; settle every guess. Here: jx_account, jx_receipt, jx_receipt_pdf_link read (the person confirms), so they move to readOnly; the readOnly? guesses are confirmed; every WRITE stays confirm; jx_notif_set is confirmWhenArgs. The older test tools are not part of this job: hidden. The server answers an ended session as {ok:false, error:{code:"NOT_CUSTOMER_SESSION"}} with HTTP 200, so list it in reauthOn (and tell the server's owner it should be a 401).
Templates: call each read tool once (signed in, test data) and bind against what came back, e.g. jx_account's data.accounts[*] with FinanceAccountNumber, Balance, AccountState, NextDueDate.
4. The file
{
"schema": "streamline.personal/1",
"id": "jx_self_service",
"name": "My finance account (test)",
"description": "Balance, cards, addresses and receipts on the fake JX test server. Invented data only.",
"icon": "creditcard.fill",
"color": { "light": "#1D5C8C", "dark": "#5AA9E6" },
"instructions": "You help one customer with one question or change about their own finance account per chat. The session is the customer: tools take no customer id. Reads run at once; every change waits for the customer's tap on a confirmation card, so describe the change before you ask. A result with ok:false is the system's answer (not found, not allowed): relay it plainly. Never ask for a card number, password or Social Security number in chat.",
"model": { "provider": "anthropic", "id": "claude-haiku-5-5", "effort": "low", "maxTokens": 4096 },
"mcp": [ {
"id": "jx", "name": "Fake JX", "url": "http://localhost:8792/mcp", "auth": "oauth",
"tools": {
"readOnly": ["jx_account", "jx_card_list", "jx_address_list", "jx_payment_preview", "jx_item_search", "jx_layaway_preview", "jx_receipt", "jx_receipt_pdf_link"],
"confirmWhenArgs": ["jx_notif_set"],
"confirm": ["jx_card_deactivate", "jx_address_add", "jx_address_deactivate", "jx_payment_add", "jx_layaway_create"],
"hidden": ["customer_search", "account_get", "payment_history", "item_search", "notif_set", "address_update", "contact_preference", "card_update"],
"direct": ["jx_layaway_preview"]
},
"renderers": { "jx_account": "account" },
"reauthOn": ["NOT_CUSTOMER_SESSION"]
} ],
"templates": {
"account": { "root": { "type": "list", "items": "$.accounts[*]", "dividers": true, "padding": 14, "item":
{ "type": "stack", "spacing": 6, "children": [
{ "type": "row", "distribute": "spaceBetween", "children": [
{ "type": "text", "text": "{{ @.FinanceAccountNumber | mask:4 }}", "style": "headline", "mono": true },
{ "type": "badge", "text": "{{ @.AccountState | default:'Open' }}",
"toneMap": { "path": "@.AccountState", "map": { "Current": "positive", "Past Due": "warning", "Closed": "neutral" }, "default": "neutral" } } ] },
{ "type": "money", "value": "@.Balance", "style": "title" },
{ "type": "keyValue", "rows": [ { "label": "Next due", "value": "{{ @.NextDueDate | date:'medium' }}", "when": "@.NextDueDate" } ] } ] } } }
},
"imageHosts": ["localhost"],
"welcome": "Each chat is one question or change about your account. Changes wait for your tap.",
"placeholder": "Balance, cards, addresses, a receipt…",
"quickPhrases": [ { "label": "My balance", "text": "Show my account summary", "send": true },
{ "label": "My cards", "text": "Show my cards", "send": true } ]
}
5. Check, then hand over
node scripts/check.mjs jx_self_service.streamline.json --dev # --dev only because the test URL is http://localhost
A personal file is not published: give it to the person to import (Files or AirDrop). Tell them which tools run at once, which wait for a tap, and which are hidden; and that their model key lives in the app's vault, not the file.