Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 39 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ mutation; the caller receives a fresh preview when confirmation can no longer be
`ForkableClient` selects retry behavior from the method being called. Never infer operation type by
parsing GraphQL text.

- `gql`, `gqlPublic`, and `query` use the query path. A query may retry once after a transport failure
- `gql` and `query` use the query path. A query may retry once after a transport failure
or HTTP 5xx. Callers must use these methods only for reads; retry safety depends on that contract.
- `gqlRaw` and `mutate` use the mutation path. A mutation is never retried after a transport failure,
- `mutate` uses the mutation path. A mutation is never retried after a transport failure,
redirect, HTTP 408/5xx, malformed successful response, top-level execution failure with ambiguous
data, or missing/malformed mutation payload.
- The only mutation replay is one retry after an actual first HTTP `419`, following a fresh CSRF
Expand Down Expand Up @@ -127,7 +127,6 @@ require positive ownership: `piece.userId` must equal the effective `me.id`.
- `mode: "add"` always uses `addPiece` without resolving a source piece. It cannot be combined with
`sourcePieceId`, and its input includes `userId` and `replacedPieceId: null` but no `oldPieceId`.
- `remove_meal` requires a unique id and positive ownership.
- `skip_delivery` operates only when exactly one owned piece can be resolved.
- `set_meal_all` deduplicates delivery ids and refuses a target day with multiple owned pieces; those
days must be handled individually.

Expand All @@ -142,6 +141,39 @@ fields has returned HTTP 503. Keep `confirmDelivery` on its known selection.
`replaceAllPieces.newPiece.deliveryId` is the first target delivery id, and its payload selection is
`errors`.

## Meal ratings

`rate_meal` uses the authenticated dashboard's `rateMeal` mutation with selection `errors`.
Resolve `deliveryId` and a unique, positively owned `pieceId`, then send `piece.userRating.id` as
`id` with `channel: "mc"`. A missing rating record or id is unavailable; never invent one. Buffet
ratings use a different flow and are unsupported here. The captured dashboard's `MealRating.save`
sends `attachment` as null or the stored URL and requests only `errors` from `rateMeal`. Preserve
that known request shape; the `errorDetails` behavior observed on meal-order mutations does not
establish support for that field on rating mutations. The dashboard's textarea sends a string for
comments, including empty strings. Server persistence of clearing edits has not been live-tested.

A live initial rating submission and readback succeeded with the null attachment and `errors`-only
payload selection. An omitted `allowRatingFollowUps` changed from unreported to `true` in the
readback. Describe an unreported preference as using Forkable's default, not as remaining unchanged;
do not invent a local default or change the account-wide preference.

Scores are integers from 1–5. Levels 4–5 use the dashboard's compliment codes; 1–3 use its issue
codes. Explicit incompatible reasons are rejected. Omitted reasons retain unknown server codes and
drop only known incompatible codes, even when the stored level is absent. Duplicate reasons are
removed. Other omitted feedback preserves existing values; explicit empty reasons or comments
clear them.
Preserve existing attachments, and do not call `updateUser` or invent follow-up consent.

Read projections expose nullable `rating` objects with level, reasons, comment, guest flag, and
follow-up preference. No record means unavailable; a record without a level means unrated. Mutation
IDs, channel, and attachments stay internal. Ratings and meals always use the same ownership filter.

Rating previews search from 14 days ago by default and accept `from` and `to` for bounded older
lookups, rejecting backwards ranges before making requests. Score-change previews show the old
and new score. The stored plan includes `reconciliationRange`; an uncertain outcome returns it as
`reconciliation.arguments`
for `list_deliveries`. Preserve this range through the gate's structured clone and confirmation.

## `selectionsHash`

Customization is keyed by modifier id, with arrays of option ids:
Expand All @@ -157,8 +189,8 @@ String choices resolve only by a unique trimmed, case-insensitive match: modifie
are blocking selection violations. Numeric ids remain the preferred unambiguous input.

Explicit emptiness is not absence. An explicitly empty optional single stays `[-1]`; an explicitly
empty required modifier violates the requirement. An absent choice may preserve stored selections or
use the API-ordered first option for a required default. Do not invent diet-aware defaults locally.
empty required modifier violates the requirement. An absent choice uses the API-ordered first option
for a required default. Do not invent diet-aware defaults locally.

## Thin-client validation

Expand Down Expand Up @@ -242,7 +274,6 @@ Other wire constraints:
- `delivery.state`, `delivery.simpleState`, and `order.state` are different lifecycles; do not merge
them into one source field.
- `me.roles` is a JSON feature-flags scalar, not a role-name array.
- `Piece.autoOrder` reflects account auto-order behavior, not who selected the meal.
- `club.hidePrices` is a Forkable display preference, not an API authorization boundary.

## Environment
Expand Down Expand Up @@ -270,5 +301,5 @@ bun run smoke
binary without credentials. Keep `scripts/` in the TypeScript project.

Use TypeScript strict mode, two-space indentation, kebab-case files, and snake_case tool names. Reads
use `get_`, `list_`, `search_`, `recommend_`, or `explain_`; writes use `set_`, `remove_`, `skip_`, or
`confirm_` and accept an optional `confirmToken`.
use `get_`, `list_`, `search_`, or `recommend_`; writes use `set_`, `remove_`, `confirm_`, or `rate_`
and accept an optional `confirmToken`.
30 changes: 27 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ lunch is without opening the Forkable app.
- Track courier ETAs, arrival times, and office access notes
- Browse and search menus
- Get Forkable's meal recommendations
- Add, replace, remove, skip, and confirm meals
- Add, replace, remove, and confirm meals
- Rate meals from 1–5 and leave written feedback
- Use it from any MCP client that can launch a stdio server

## Quick start
Expand Down Expand Up @@ -83,17 +84,40 @@ The source files are in [`skills/`](./skills).
| `get_menus` | Lists menus and item options for a delivery |
| `search_items` | Searches a delivery's menus |
| `recommend_meals` | Returns Forkable's meal recommendations |
| `explain_pick` | Shows where the current meal appears in Forkable's recommendations |
| `get_profile` | Shows the signed-in Forkable user |
| `set_meal` | Adds or replaces a meal |
| `set_meal_all` | Sets the same meal on several deliveries |
| `remove_meal` | Removes a meal |
| `skip_delivery` | Skips a delivery |
| `rate_meal` | Rates a meal or edits its score and text feedback |
| `confirm_delivery` | Confirms or unconfirms a delivery |

Forkable still decides whether a change is allowed, including deadlines, restaurant capacity, and
billing rules.

To skip a delivery, use `list_deliveries` and remove each selected owned meal with `remove_meal`.
This removes those meals; it does not change your auto-order settings. Compare selected meals with
`recommend_meals` when choosing alternatives.

### Meal ratings

List the delivery first, supplying `from` and `to` for past meals. Each owned meal includes `rating`:
`null` means Forkable has not made a rating available, while a rating with `level: null` is unrated.

Use `rate_meal` with the delivery ID, the meal's `pieceId`, and a `level` from 1–5. It previews first;
call again with the same arguments and its `confirmToken` to submit. The search starts 14 days ago
by default; pass both `from` and `to` to limit the lookup to an older day or week.

Optional `reasons`, `comment`, `forGuest`, and `allowRatingFollowUps` edit the feedback. Omitted fields
keep their current values; an empty comment or reason array clears it. Levels 4–5 accept compliment
codes, while 1–3 accept issue codes listed in the tool schema. Known incompatible stored reasons are
removed, unknown server codes are preserved, and duplicates are ignored. Score changes show both
the old and new score in the preview. Marking a meal as a guest meal excludes its rating from future
suggestions. Follow-up preferences apply to this rating without changing your account settings.
If Forkable has not reported a follow-up preference, omitting it lets Forkable apply its default,
which may allow its team to contact you about your feedback. Set `allowRatingFollowUps: false`
to opt out for the rating.
Existing attachments are kept; photo editing and buffet ratings are not supported.

## Authentication

There is no API key. The server reuses a Forkable web session and stores it in
Expand Down
15 changes: 13 additions & 2 deletions scripts/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,17 +22,16 @@ const exec = promisify(execFile);

const EXPECTED_TOOLS = [
"confirm_delivery",
"explain_pick",
"get_delivery_status",
"get_menus",
"get_profile",
"list_deliveries",
"rate_meal",
"recommend_meals",
"remove_meal",
"search_items",
"set_meal",
"set_meal_all",
"skip_delivery",
];

const log = (msg: string) => console.log(` ${msg}`);
Expand Down Expand Up @@ -120,6 +119,18 @@ async function checkInstalled(runner: Runner, cwd: string, home: string): Promis
}
log(`${runner} write schemas require exact menu identity and expose additional meals`);

const rating = listedTools.find((tool) => tool.name === "rate_meal");
const ratingSchema = rating?.inputSchema as
| { required?: string[]; properties?: Record<string, unknown> }
| undefined;
for (const name of ["deliveryId", "pieceId", "level"]) {
if (!ratingSchema?.required?.includes(name))
fail(`${runner}: rate_meal does not require ${name}`);
}
if (!ratingSchema?.properties?.confirmToken)
fail(`${runner}: rate_meal does not expose confirmToken`);
log(`${runner} rating schema requires an exact meal and score`);

const res: any = await client.callTool({ name: "get_profile", arguments: {} });
const text = (res.content ?? []).map((content: any) => content.text ?? "").join("");
if (!text.trim()) fail(`${runner}: get_profile returned no content`);
Expand Down
36 changes: 30 additions & 6 deletions skills/forkable/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
name: forkable
description: >-
Use the forkable MCP server to read, choose, change, skip, confirm, and track meals. Use for
Use the forkable MCP server to read, choose, change, rate, confirm, and track meals. Use for
Forkable delivery, menu, recommendation, meal, and courier-status requests, including workflows
that also use a more focused Forkable skill.
---
Expand Down Expand Up @@ -78,13 +78,35 @@ extra meal is covered or will be charged; Forkable decides that.
duplicate delivery IDs. A delivery with several owned meals must be handled individually with
`set_meal` and `sourcePieceId`.

`remove_meal` requires an owned `pieceId`. `skip_delivery` removes the only positively owned
meal on a delivery; use `remove_meal` separately when more than one is owned.
`remove_meal` requires an owned `pieceId`. To skip a delivery, list it and remove each meal the user
wants removed, using its exact owned `pieceId`. This does not disable auto-ordering for future days.

`confirm_delivery` confirms by default. Pass `confirm: false` to unconfirm without removing the
meal. `set_meal` can use `autoConfirm` when the user wants the replacement and confirmation in one
mutation.

## Rate a meal

Use `rate_meal` for a 1–5 score or feedback edits on one owned meal. List past deliveries with explicit
`from` and `to`, then use the returned `deliveryId` and `pieceId`. The rating tool searches from 14
days ago by default; pass both `from` and `to` to bound older lookups to the meal's day or week.
A meal's `rating: null` means unavailable, while a rating object with `level: null` means unrated.
Do not infer rating availability from delivery status.

Ask for the user's actual score and feedback; do not manufacture a rating from their food preferences.
Optional reasons use the tool schema's compliment codes for 4–5 and issue codes for 1–3. Omitted
feedback stays unchanged; explicit empty comments or reason arrays clear it. Known incompatible
stored reasons are removed, unknown server codes are preserved, and duplicates are ignored.
Existing attachments are kept. Check the old and new score shown in a score-change preview.

`forGuest: true` excludes this rating from the user's future meal suggestions. Set
`allowRatingFollowUps` only when the user states a preference; it applies to this rating and does not
change account settings. Show the exact score, feedback, and preferences in the preview before
confirming. If the preference is unreported, explain that omission lets Forkable apply its default,
which may enable contact about the rating. Do not describe that as preserving a known preference.
Use delivery lists and recommendations to compare meals; no tool explains the model's
reasoning or reports ranks beyond the returned recommendations.

## Dietary advisory

While creating a `set_meal` or `set_meal_all` preview, the server calls Forkable's
Expand Down Expand Up @@ -123,7 +145,8 @@ Review the replacement preview before using its new token.
- `rejected`: Forkable definitively refused the write. Stop and report the reasons; do
not reuse the consumed token.
- `outcome_unknown`: Forkable may have applied the write. Do not retry. Refresh the delivery IDs
named in `reconciliation` with `list_deliveries`, then compare the current state.
named in `reconciliation` with `list_deliveries`, passing `reconciliation.arguments` when present
so historical ratings are included, then compare the current state.

Mutations are not retried after an ambiguous transport or server failure.

Expand All @@ -138,5 +161,6 @@ Quote them as reported. Do not calculate company coverage or an authoritative ou

## Unsupported account actions

The tools do not rate meals, report missing or incorrect items, change vacation settings, edit
Forkable dietary settings, or switch offices. Direct the user to Forkable for those actions.
The tools do not submit buffet ratings, edit rating photos, report missing or incorrect items,
change vacation settings, edit Forkable dietary settings, or switch offices. Direct the user to Forkable
for those actions.
11 changes: 0 additions & 11 deletions src/config.ts

This file was deleted.

3 changes: 1 addition & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@
// `bun run src/index.ts` → serve MCP over stdio (client-spawned).

import { runStdio } from "./server.ts";
import { loadConfig } from "./config.ts";
import { runAuthCli } from "@/auth/cli.ts";

const argv = process.argv.slice(2);
if (argv.includes("--auth")) {
await runAuthCli(argv);
} else {
await runStdio(loadConfig());
await runStdio();
}
49 changes: 7 additions & 42 deletions src/net/client.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
// Authenticated GraphQL client with operation-aware retry and cookie rotation.

import {
ENDPOINT,
PUBLIC_ENDPOINT,
CSRF_URL,
forkableHeaders,
type FetchImpl,
} from "./endpoints.ts";
import { ENDPOINT, CSRF_URL, forkableHeaders, type FetchImpl } from "./endpoints.ts";
import {
ReauthRequiredError,
MutationError,
Expand Down Expand Up @@ -84,17 +78,12 @@ type Operation = "query" | "mutation";
interface RequestOptions {
operation: Operation;
operationName: string;
public: boolean;
queryRetries: number;
csrfRetries: number;
}

function requestOptions(
operation: Operation,
operationName: string = operation,
isPublic = false,
): RequestOptions {
return { operation, operationName, public: isPublic, queryRetries: 0, csrfRetries: 0 };
function requestOptions(operation: Operation, operationName: string = operation): RequestOptions {
return { operation, operationName, queryRetries: 0, csrfRetries: 0 };
}

function isRecord(value: unknown): value is Record<string, unknown> {
Expand Down Expand Up @@ -206,18 +195,13 @@ export class ForkableClient {
variables: Record<string, unknown> | undefined,
options: RequestOptions,
): Promise<GqlResponse<T>> {
if (!options.public && !this.csrf) await this.mintCsrf();
if (!this.csrf) await this.mintCsrf();

const endpoint = options.public ? PUBLIC_ENDPOINT : ENDPOINT;
const headers = forkableHeaders(
this.cookie,
options.public ? undefined : this.csrf,
this.delegation,
);
const headers = forkableHeaders(this.cookie, this.csrf, this.delegation);

let res: Response;
try {
res = await this.fetchImpl(endpoint, {
res = await this.fetchImpl(ENDPOINT, {
method: "POST",
redirect: options.operation === "mutation" ? "manual" : "follow",
headers,
Expand Down Expand Up @@ -247,7 +231,7 @@ export class ForkableClient {
const setCookies = res.headers.getSetCookie?.() ?? [];
if (setCookies.length) await this.persist({ setCookies });

if (!options.public && res.status === 419) {
if (res.status === 419) {
if (options.csrfRetries < 1) {
try {
await this.mintCsrf();
Expand Down Expand Up @@ -380,32 +364,13 @@ export class ForkableClient {
return body;
}

/** Low-level POST using mutation-safe retry behavior. */
async gqlRaw<T = unknown>(
query: string,
variables?: Record<string, unknown>,
o: { public?: boolean; retried?: number } = {},
): Promise<GqlResponse<T>> {
return this.sendGraphql<T>(query, variables, {
...requestOptions("mutation", "mutation", o.public ?? false),
csrfRetries: o.retried ?? 0,
});
}

/** Run a query document, throwing on GraphQL errors; returns `data`. */
async gql<T = unknown>(query: string, variables?: Record<string, unknown>): Promise<T> {
const r = await this.sendGraphql<T>(query, variables, requestOptions("query"));
if (r.errors?.length) throw new QueryError(r.errors);
return (r.data ?? null) as T;
}

/** Public (unauthenticated) endpoint — e.g. `identities`, `diets`. */
async gqlPublic<T = unknown>(query: string, variables?: Record<string, unknown>): Promise<T> {
const r = await this.sendGraphql<T>(query, variables, requestOptions("query", "query", true));
if (r.errors?.length) throw new QueryError(r.errors);
return (r.data ?? null) as T;
}

/** Sugar: `query("menus", {ids,clubId}, "id name")` → returns `data.menus`. */
async query<T = unknown>(
root: string,
Expand Down
Loading
Loading