Skip to content

Resources

  • Lesson 5
  • 50 min
  • Module 2: Design useful server capabilities

What you will be able to do

Give every policy document a stable address that never changes, so that an application can list it, read it whole and cite it. Then decide who may read which document, and give one answer for every refusal.

Before you startWhat to know and to have readyAbout 50 minutes

What you need to know

  • Tools, contracts and bounds, from Tools.
  • Organizations and roles (owner, staff, read_only), from the authentication course’s Users and organizations.

Your project

  • ~/projects/support-mcp after the last lesson, with the virtual environment active: python -m pytest -q prints 20 passed.
  • This lesson adds no file. It reads the resource part of support_mcp/contracts.py.

Think of a library. Every book has a shelf mark, such as HIS 942.1 ROW. The mark tells you where the book is, and it stays the same for years. When a new edition arrives, the book keeps its mark, and only the edition on the first page changes.

You can find a book in two ways. You can read the catalogue, a list of every mark. Or you can work out a mark from the pattern: section, number, author. Both ways lead to the same shelf.

You also choose a book yourself, and you read all of it. The librarian does not choose for you, and does not hand you one sentence from page 40.

Some books stay in a staff room. The librarian checks your card before handing one over. If you may not see it, the answer is “we do not have that”.

Grace owns Larkfield’s policies. She asks Amira a simple question: “When the assistant answers about a damaged item, what does the model read?”

Amira shows her. The search tool returns a snippet of the right policy, as the contract allows. Here is that snippet, from the course’s recording of search_knowledge for “return a damaged item”:

… no, you can keep or recycle the damaged item. - Items of 50 dollars or more: we send a free return label, or arrange a …

The full policy, damaged-or-wrong-items, has 867 characters and six sections. The first section is “Report within 14 days”. The snippet starts in the middle of the third. A reply written from it can tell a customer to keep a cheap item, and forget the 14 days and the photo. A bounded search result is right for finding a policy. It is wrong for quoting one.

Tomás suggests another tool: get_policy(name). Kwame asks two questions. First, who decides when to read the whole policy? With a tool, only the model decides. Sam cannot open the policy himself, or attach it to the conversation. Second, what is the policy’s name? “The third search result” changes when the ranking changes. A reply that cites a policy needs an address that stays.

A resource is content that a server offers for reading, such as a document. Each resource has a URI, a Uniform Resource Identifier: a short string that names one thing, like a web address. The application, or the person, chooses which resources to read. The model does not choose. That is the main difference from a tool.

A client finds resources in two ways, like the library’s catalogue and its pattern:

  • resources/list returns the list of concrete resources, each with its URI, a name, a title and a type.
  • resources/templates/list returns resource templates: patterns with placeholders in braces, such as policy://{tenant}/{doc_id}. A template describes a whole family of URIs without listing each one. The pattern follows a standard, URI Template (RFC 6570).

Then resources/read with one URI returns the content, whole.

Amira’s design has one URI per policy document:

policy://larkfield/returns-policy

It has three parts. policy:// is the scheme, a name that Larkfield chooses for its own kind of content. larkfield is the organization. returns-policy is the document’s ID. The ID stays the same when the text changes; the document’s version number changes instead. So a reply that cites policy://larkfield/returns-policy still points to the returns policy next year.

The resource part of contracts.py holds the template, and a regular expression that takes a URI apart:

support_mcp/contracts.py (the resource part)
POLICY_URI_TEMPLATE = "policy://{tenant}/{doc_id}"
POLICY_URI = re.compile(r"^policy://(?P<tenant>[a-z]+)/(?P<doc_id>[a-z0-9-]{1,60})$")
def policy_uri(tenant: str, doc_id: str) -> str:
return POLICY_URI_TEMPLATE.format(tenant=tenant, doc_id=doc_id)

POLICY_URI allows only lowercase letters for the organization, and only lowercase letters, digits and - for the ID, up to 60 characters. Here is a real Python session with it:

>>> from support_mcp import contracts
>>> m = contracts.POLICY_URI.match("policy://larkfield/returns-policy")
>>> m["tenant"], m["doc_id"]
('larkfield', 'returns-policy')
>>> contracts.POLICY_URI.match("policy://larkfield/../secrets")
>>> contracts.POLICY_URI.match("https://larkfield.example/returns")
>>>

The first URI matches, and the two parts come out. The other two print nothing: match returns None, so they are not policy URIs. ../secrets tries to climb out of the folder, as in a file path. The https:// address is a web page, not a Larkfield document. Neither can reach the business service.

A correct form is only the first check. Before the server answers resources/read, it makes three:

At the top, the URI policy://larkfield/returns-policy in three parts: the scheme policy://, our own name; the organization larkfield; the document ID returns-policy, which stays when the text changes. The template for the whole family: policy://{tenant}/{doc_id}. Below, the server's three checks before it answers resources/read, from left to right, each joined by 'yes': 1. Does it have the form policy://{tenant}/{doc_id}? 2. Is {tenant} the caller's own organization? 3. Does the document exist, and may this caller read it? After the third yes: the text, as text/markdown. A 'no' at any check leads to one red box: the same answer, −32602 Resource not found. At the bottom, four requests. policy://larkfield/gift-cards by Sam (staff, Larkfield): the Markdown text, 433 characters. policy://bramble/returns-policy by Sam: −32602 Resource not found, at check 2. policy://larkfield/refund-approval-procedure by Omar (read-only, Larkfield): not found at check 3, staff only. policy://larkfield/../secrets by anyone: not found at check 1, no match.
A policy URI and the server's three checks. Rows 1 and 2 are recorded answers of the finished server; rows 3 and 4 are this lesson's Python runs.
Text description of the diagram

The URI policy://larkfield/returns-policy has three parts: the scheme policy://, the organization larkfield, and the document ID returns-policy, which stays when the text changes. The template for the family is policy://{tenant}/{doc_id}.

The server checks three things, in order. A “no” at any check gives the same answer, −32602 “Resource not found”.

  1. Does the URI have the form policy://{tenant}/{doc_id}?
  2. Is {tenant} the caller’s own organization?
  3. Does the document exist, and may this caller read it?
URI Caller Answer
policy://larkfield/gift-cards Sam (staff, Larkfield) the Markdown text, 433 characters
policy://bramble/returns-policy Sam −32602 Resource not found (check 2)
policy://larkfield/refund-approval-procedure Omar (read-only, Larkfield) not found (check 3: staff only)
policy://larkfield/../secrets anyone not found (check 1: no match)

Look at the second row. Bramble Books really has a document called returns-policy. Even so, the answer is the same as for a document that does not exist. If the answer were “forbidden”, Sam would learn that Bramble has a returns policy, and could guess its other documents. One answer for both cases tells a caller nothing about the other organization. The authentication course teaches the same rule in Tenant-safe queries.

The organization in the URI is never a key that opens a door. The server compares it with the caller’s own organization, which it learns from the connection, as for tools.

Larkfield has 9 policy documents. One of them, refund-approval-procedure, is for staff only: it tells agents how to approve manual refunds. So the list depends on the caller. Here is a real session with the business service:

>>> from support_mcp.knowledge import Knowledge
>>> k = Knowledge()
>>> for d in k.documents("larkfield", staff=True): print(contracts.policy_uri("larkfield", d["doc_id"]), d["access"], d["version"])
...
policy://larkfield/damaged-or-wrong-items public 2
policy://larkfield/delivery-policy public 4
policy://larkfield/gift-cards public 1
policy://larkfield/order-changes-and-cancellations public 2
policy://larkfield/refund-approval-procedure staff 2
policy://larkfield/refunds-and-payments public 3
policy://larkfield/returns-policy public 3
policy://larkfield/supplier-aquaflow-hose-reels public 1
policy://larkfield/warranty-policy public 5
>>> len(k.documents("larkfield", staff=True)), len(k.documents("larkfield", staff=False))
(9, 8)
>>> k.document("larkfield", "refund-approval-procedure", staff=False)
support_mcp.knowledge.NotFound: Not found.

Sam, who is staff, gets 9 URIs. Omar, who is read-only, gets 8. When a read-only caller asks for the staff document by its URI, the business service answers “Not found.” The version numbers show the stable IDs at work: the delivery policy is in its fourth version, and its URI has never changed.

In Module 3, the server sends this list as the answer to resources/list. The course’s recording of the finished server, for Sam, is 1,806 bytes with 9 entries. Here is one of them:

{"description": "policy, version 3",
"mimeType": "text/markdown",
"name": "returns-policy",
"title": "Returns policy",
"uri": "policy://larkfield/returns-policy"}

mimeType says what kind of text comes back: Markdown. The answer to resources/templates/list is 392 bytes. It holds one template, policy://{tenant}/{doc_id}, named policy: “The full text of one policy document of your organization.” And here are the two recorded answers to resources/read, shortened:

{"contents": [{"mimeType": "text/markdown",
"text": "# Gift cards\n\n## Buying a gift card\nWe sell gift cards from 25 to 500 dollars. …",
"uri": "policy://larkfield/gift-cards"}]}
{"code": -32602, "message": "Resource not found: policy://bramble/returns-policy",
"data": {"uri": "policy://bramble/returns-policy"}}

The first is a result: the whole gift card policy, 433 characters of Markdown. The second is an error with code −32602, which MCP uses for a resource that is not found. Module 3 explains error codes.

How much text does a resource return? The whole document. Here are the nine Larkfield documents, next to the 300 characters that a search snippet allows:

  • one Larkfield document (a resource)
  • the search snippet limit
The text of each of the 9 Larkfield documents, in characters, from the project's data. A search snippet is at most 300 characters; a resource returns the whole text.
Text description of the chart

A dot plot with two rows, from 0 to 3,000 characters. The first row has nine dots, one per Larkfield document: damaged-or-wrong-items 867, delivery-policy 2,150, gift-cards 433, order-changes-and-cancellations 724, refund-approval-procedure 1,059, refunds-and-payments 1,432, returns-policy 1,744, supplier-aquaflow-hose-reels 873, warranty-policy 2,846. The second row has one dot, the snippet limit, at 300. Every document is longer than a snippet. The shortest, gift cards, has 433 characters; the longest, the warranty policy, has 2,846.

Every document is longer than a snippet. That is the reason for both capabilities: the search finds the right policy, and the resource gives its whole text. The search result even carries the resource’s URI, so the step from one to the other is short.

The same server can offer the same policy in two ways. The difference is who decides to read it. The sequence below is a design for a host, such as the help desk assistant. The course’s own host, in Module 4, uses only tools.

A sequence between four participants: Person (Sam), Host (the help desk assistant), Model, Server (support-mcp). The host sends resources/list to the server, which answers 9 policy URIs for Sam, 8 for a read-only member. Note: the application chooses a resource. Sam attaches 'Returns policy' in the host. The host sends resources/read policy://larkfield/returns-policy, and the server answers with the whole document in Markdown. The host sends Sam's question and the attached text to the model. Note: the model chooses a tool. The model proposes search_knowledge. The host sends tools/call search_knowledge, and the server answers 3 matches with snippets of at most 300 characters, each with its policy:// URI. The host gives the matches to the model.
A resource is chosen by the application or the person; a tool is chosen by the model. A design for a host, with the counts of support-mcp.
Text description of the diagram
  1. The host sends resources/list. The server answers with 9 policy URIs for Sam (8 for a read-only member).
  2. The application chooses a resource. Sam attaches “Returns policy”. The host sends resources/read policy://larkfield/returns-policy, and gets the whole document in Markdown.
  3. The host sends Sam’s question and the attached text to the model.
  4. The model chooses a tool. It proposes search_knowledge. The host sends tools/call, and gets 3 matches: snippets of at most 300 characters, each with its policy:// URI.
  5. The host gives the matches to the model.
  1. Start Python in the project folder, with the virtual environment active:

    Terminal window
    python
  2. Type the lines of the two sessions above, from from support_mcp import contracts to k.document(…). Compare each answer with the lesson. Only the Traceback lines before NotFound differ.

  3. Now try Bramble Books. Type len(k.documents("bramble", staff=False)). It prints 4: Bramble Books has 5 documents, and one of them, supplier-terms, is for staff only.

  4. Leave Python with exit().

This lesson adds no file and no test. The contract test test_policy_uris_are_stable_and_parseable, from the last lesson, already checks the template, the parts and the refused ../secrets.

Use a resource for content that someone reads whole, and that has a stable identity: a policy, a manual page, a product sheet. It fits when the application or the person should choose it, and when a reply must cite it.

Do not use a resource for everything:

  • Customer records, such as tickets. A ticket is personal data, and the assistant needs one at a time, for one task. get_ticket lets the model ask for the ticket that it works on, with a bounded result. A resources/list of every ticket would be an unbounded list of customers.
  • Answers that need a computation, such as a search. The words change every time, so there is nothing stable to name. That is a tool.
  • Large or growing content. A resource returns everything. Larkfield’s longest policy is 2,846 characters, which is fine. A 300-page manual should be split into sections, each with its own URI.

Resources also have costs:

  • The whole text enters the context. Attach three long policies, and the model reads all of them on every turn.
  • The list itself needs checks. The list depends on the caller: 9 documents for Sam, 8 for Omar. So resources/list must check the caller, like resources/read.
  • Hosts differ. The protocol defines resources, but each host decides how to show them, or whether to use them. Check each host that you plan to support.

Common mistake

A URI that never matches

Symptom: every read of a document fails, although the document exists. In Python, contracts.POLICY_URI.match("policy://Larkfield/Returns_Policy") prints nothing: the result is None.

Cause: the pattern allows only lowercase letters, digits and -. Larkfield has a capital letter, and Returns_Policy has a capital letter and an underscore.

Fix: build URIs with contracts.policy_uri(tenant, doc_id) from the IDs in the data, never by hand. Do not loosen the pattern: a strict form is the first check.

Common mistake

A staff document that seems to be missing

Symptom: k.document("larkfield", "refund-approval-procedure", staff=False) raises NotFound: Not found., but the document is in data/documents.jsonl.

Cause: this is the access check. The document is marked staff, and the call asked as a read-only member.

Fix: nothing to fix. Use staff=True only for owners and staff. In Module 3, the server takes this value from the caller’s role in the membership table, never from the request.

Reset. This lesson changes no file. If your project is broken, copy the snapshot end-of-m02 as in Where MCP fits, with end-of-m02 instead of start.

Check your understanding

For each need, choose a tool or a resource, and say who chooses: (a) Sam wants to attach the full warranty policy to a conversation; (b) the model must find which policy talks about double charges; (c) a reply must cite the returns policy so that a reader can open it later.

Show the answer

(a) A resource: policy://larkfield/warranty-policy. Sam, through the application, chooses it, and it comes back whole. (b) A tool: search_knowledge. The model decides during the conversation that it must look something up, and gets bounded matches. (c) A resource URI: policy://larkfield/returns-policy. It stays the same when the policy’s text changes, so the citation still works later. The search result carries this URI, so the model can name it.

Check your understanding

Predict the answers of the finished server: (a) Omar (read-only, Larkfield) sends resources/list; (b) Omar reads policy://larkfield/refund-approval-procedure; (c) Sam reads policy://bramble/returns-policy, a document that exists at Bramble Books.

Show the answer

(a) 8 resources: every Larkfield document except the staff-only refund-approval-procedure. (b) −32602 “Resource not found”, at check 3: the document exists, but a read-only member may not read it. (c) −32602 “Resource not found: policy://bramble/returns-policy”, with data.uri, at check 2. That is the same answer as for a document that does not exist, so Sam learns nothing about Bramble Books.

Check your understanding

Practical check: in Python, what do these print: contracts.policy_uri('larkfield', 'returns-policy'), contracts.POLICY_URI.match('policy://larkfield/../secrets'), and len(k.documents('larkfield', staff=False))?

Show the answer

'policy://larkfield/returns-policy'; nothing, because the result is None (the form is refused); and 8, because the staff-only procedure is not listed for a read-only caller.

Recap

  • A resource is content to read whole, with a URI. The application or the person chooses it; the model does not.
  • A stable URI, policy://larkfield/returns-policy, keeps the document’s ID when its text changes. The version changes instead.
  • A resource template, policy://{tenant}/{doc_id}, describes the whole family. resources/list lists the concrete URIs, per caller: 9 for Sam, 8 for Omar.
  • Before resources/read, the server checks the form, the organization and the caller’s right to read. Every refusal is the same −32602 “Resource not found”.
  • Search finds a policy with a 300-character snippet; the resource gives all of it, up to 2,846 characters at Larkfield.

Your project still prints 20 passed. Next, you design the last kind of capability: a task that the person starts.

Next: Prompts