Contents

Docs / Concepts

GraphQL APIs

How Sokie makes tools from a GraphQL schema: where the schema comes from, what each tool asks for, and what it can't do yet.

Sokie reads a GraphQL schema the way it reads an OpenAPI spec. Every field on Query and Mutation is an operation, each tool calls one of them, and the field's arguments become the tool's input. You never write a query: Sokie writes each tool's document when it builds the server, and an agent passes arguments and gets the field's data back.

Start in the app

sokie init reads OpenAPI and Swagger specs only, for now: given a .graphql file, it stops and says GraphQL comes later. Make a GraphQL server from New server in the app instead. It takes the schema three ways:

  • From a URL: give the API's endpoint, such as https://api.example.com/graphql. If the URL serves a schema file, Sokie reads that. Otherwise it sends the endpoint GraphQL's introspection query and uses the schema that comes back. If the endpoint wants a key even for that, tick It needs a header to read: the header goes with that read only, and is never stored. Sokie fetches over https, and never from an address inside a network.
  • Paste a spec, or drop a .graphql file: SDL, which Sokie knows by its type Query, type Mutation or schema { }, or a saved introspection result, which is JSON with a __schema in it.
  • A GitHub repository you've already linked to a server: its .graphql and .gql files are offered, and so are JSON files named like a spec, such as schema.json.

Sokie says what it found, as GraphQL (SDL) or GraphQL (introspection); if it guessed wrong, pick GraphQL under Spec type. If the API has introspection turned off, reading its endpoint fails; paste or drop its SDL instead.

A schema doesn't say where its API is served, so the page asks Where the API answers: the endpoint every tool calls, which becomes upstream.baseUrl in sokie.json. When the schema came from introspection, the field is already filled in with that endpoint.

The schema is committed with the server's files, as schema.graphql for SDL or schema.json for an introspection result. A dropped file, or one from a repository, keeps its own name. A build reads only that copy, so when the API's schema changes, update the file.

Which fields become tools

Every field on Query and Mutation becomes an operation, which a tool file names as query <field> or mutation <field>. Subscriptions aren't read. Take this schema:

scalar DateTime
type User { id: ID! name: String }
type Project { id: ID! name: String! owner: User! }
type Comment { id: ID! body: String! author: User! }
type Task {
  id: ID!
  title: String!
  done: Boolean!
  due: DateTime
  project: Project!
  comments(first: Int): [Comment!]!
}
input NewTask { title: String! projectId: ID! due: DateTime }
type Query {
  "Tasks in a project, newest first."
  tasks(projectId: ID!, first: Int, done: Boolean): [Task!]!
}
type Mutation {
  createTask(input: NewTask!): Task!
  deleteTask(id: ID!): Boolean!
}

It has three operations, and the new server gets a file for each, named for its field: tools/tasks.md, tools/createTask.md and tools/deleteTask.md.

---
operation: query tasks
risk: read
---

Tasks in a project, newest first.

A field's description in the schema becomes the tool's description, and each argument's description goes into its input. A field with none gets Calls mutation createTask., which sokie check flags as too thin for an agent to choose from. Make tools easy for agents to use says what to write instead.

To keep a field from agents, leave it out on the New server page, or delete its file later. A GraphQL schema has no x-mcp: false, so sokie check lists fields without a tool as a note, and sokie tools --unexposed names the file each one would take.

Arguments become the input

Each argument is a property of the tool's input, and an argument marked ! is required. Arguments reach the API as GraphQL variables; one the agent leaves out isn't sent, so the API's default applies. Types map like this:

  • String and ID are strings, Int an integer, Float a number and Boolean a boolean.
  • Date, DateTime, UUID and URL are strings with that format.
  • An enum is a string limited to its values. A list is an array.
  • An input type is an object with its own required fields, and no others allowed.
  • Any other custom scalar is a string, and sokie check warns. So does an input type that contains itself, which accepts any object where it repeats.

What each tool asks for

Sokie writes each tool's document when it builds the server. For tasks above, it's:

query QueryTasks($projectId: ID!, $first: Int, $done: Boolean) {
  tasks(projectId: $projectId, first: $first, done: $done) {
    id
    title
    done
    due
    project {
      id
      name
    }
    comments(first: 10) {
      id
      body
    }
  }
}

The selection follows a few rules, so the document is always one the schema accepts:

  • It takes every field of the type the operation returns, and of the objects under it, four levels deep. Past that, and at any type already on the way down, it asks only for __typename, and sokie check warns.
  • A field with a required argument is left out, since the tool has nothing to pass it. A field with an optional first or last, like comments above, is asked for its first 10 items: APIs like GitHub's refuse a connection asked for without one, though their schemas say both are optional. Any other field whose arguments are all optional is asked for without them.
  • Inside nested objects, a field that returns one non-null object, like owner: User!, is left out. If the API returns null there anyway, GraphQL throws away the whole response.
  • For a union or an interface, it asks for __typename and each concrete type's fields, for up to eight types. A field two types declare differently is aliased per type, as Issue_state: state.
  • The operation is named for its field, QueryTasks or MutationCreateTask, so the API's own logs show which tool called.

The selection can't be changed in a tool file. A tool returns the field's own value, data.tasks here, not the whole response. To hand the agent less, add a JMESPath project: to the tool's file; it applies to that value, so project: "[].{id: id, title: title, done: done}" keeps three fields of each task.

If the field came back, the tool returns it, and if the response also lists errors, their messages come after the data, so the agent knows part of it failed. If the field came back null with errors, the call fails with the API's error messages, and an HTTP error status fails it too.

Set each tool's risk

A query is a read, so its file starts with risk: read. A mutation starts with no risk: write, its floor, applies, and sokie check warns until you set one. Raise it where it fits: delete for a mutation that removes something, external_effect for one that acts outside the API, like sending an email. A risk can't go below the floor, so a mutation is never a read.

---
operation: mutation deleteTask
risk: delete
idempotent: true
---

Delete a task and its comments by the task's id. It can't be undone, so check the id with `tasks` first.

Clients see a read as read-only and safe to retry. A mutation isn't safe to retry unless its file says idempotent: true, and delete and external_effect are marked destructive. Left at write, deleteTask gets an effect-understated warning from sokie check, since its name says delete. On the New server page, you can set each tool's effect before anything is made.

Give the API its credentials

A schema can't say how its API signs in, so a new GraphQL server sends no credentials, and the header you gave to read the schema isn't kept. Add upstream.auth to sokie.json. For a bearer token that each person who connects brings for themselves:

{
  "upstream": {
    "baseUrl": "https://api.example.com/graphql",
    "auth": {
      "type": "apiKey",
      "in": "header",
      "name": "Authorization",
      "prefix": "Bearer ",
      "key": "perCaller"
    }
  }
}

For one key that serves every caller, make key a secret, like {"secret": "TASKS_TOKEN"}, declare it under secrets and set it with sokie secrets set (Add secrets). OAuth to the API, and fixed headers under upstream.headers, work as they do for any server; How clients sign in covers each.

Every GraphQL tool sends the credential, and a server that uses each caller's own key must be private. Sokie asks a caller for a new key when the API answers 401, or when it reports the key as refused inside a 200, with an error code like UNAUTHENTICATED or invalid_token, as many GraphQL APIs do.

To change sokie.json on a server you made in the app, choose Download files on its Tools tab, edit the file and run sokie deploy --server <slug>, or link a repository and push.

Large schemas

If you keep more than 40 tools, the New server page sets "discovery": "search", and clients get three tools, searchTools, getToolDefinition and useTool, instead of the whole list. Many operations explains how search ranks. A GraphQL tool without tags is tagged with its operation type and field, so a search for mutation finds the mutations.

Limits

  • sokie init doesn't read GraphQL yet; start in the app.
  • Subscriptions aren't tools. A tool file that names one is an unknown-operation error.
  • Each tool calls one root field, with the document Sokie writes. You can't write your own query, add fragments or pick fields.
  • Every call is a JSON POST of query and variables to upstream.baseUrl, so multipart file uploads don't work: an Upload argument is a custom scalar, sent as a string.
  • An SDL file must define every directive it uses. One that uses federation's @key without defining it doesn't parse; read the schema by introspection instead.
  • A schema as SDL can be up to 2 MiB, and as introspection JSON up to 14 MiB.