Combining present hype (MCP)

with past (GraphQL),

the road to malleable* software

Arthur Juchereau

Principal Engineer at Welbi

Hey, my name is

GraphQL

We're June 19th 2015
you are cool if you use GraphQL

First there was the schema*

type Attendance {
  program: Program!
  resident: Resident!
  status: String!
}

type Program {
  attendees: [Resident!]!
  id: ID!
  title: String!
}

type Resident {
  attendance: [Attendance!]!
  id: ID!
  name: String!
}

type Mutation {
  createProgram(title: String!): Program!
  createResident(name: String!): Resident!
  deleteAttendance(programId: String!, residentId: String!): Boolean!
  deleteProgram(id: String!): Boolean!
  deleteResident(id: String!): Boolean!
  recordAttendance(programId: String!, residentId: String!, status: String!): Attendance!
  updateAttendance(programId: String!, residentId: String!, status: String!): Attendance!
  updateProgram(id: String!, title: String!): Program!
  updateResident(id: String!, name: String!): Resident!
}

type Query {
  attendanceByProgram(programId: String!): [Attendance!]!
  attendanceByResident(residentId: String!): [Attendance!]!
  program(id: String!): Program
  programs: [Program!]!
  resident(id: String!): Resident
  residents: [Resident!]!
}

Then the request

query {
  resident(id:"1"){
    id
    name
    attendance{
      program{
        name
      }
      status
    }
  }
  second:resident(id:"2"){
    id
    name
    attendance{
      status
    }
  }
}

Validated

against the schema

Resolve

query {
  resident(id:"1"){
    id
    name
    attendance{
      program{
        name
      }
      status
    }
  }
  second:resident(id:"2"){
    id
    name
}
builder.queryType({
  fields: (t) => ({
    resident: t.field({
      type: ResidentRef,
      nullable: true,
      args: {
        id: t.arg.string({ required: true }),
      },
      resolve: (_, { id }) => findResidentById(id),
    }),
  }),
});
[
  {
    "id": "1",
    "name": "John Smith"
  },
  {
    "id": "2",
    "name": "Mary Johnson"
  }
]
const ResidentRef = builder.objectRef<Resident>('Resident').implement({
  fields: (t) => ({
    id: t.exposeID('id'),
    name: t.exposeString('name'),
    attendance: t.field({
      type: [AttendanceRef],
      resolve: (resident) => {
        const attendance = readAttendance();
        return attendance.filter(a => a.residentId === resident.id);
      },
    }),
  }),
});

Side note on Auth

    resident: t.field({
      type: ResidentRef,
      nullable: true,
      args: {
        id: t.arg.string({ required: true }),
      },
      resolve: (_, { id }, context) => {
        // Auth check: verify user has permission to read this specific resident
        const requiredGrant = `read_resident_${id}`;
        if (!context.grants?.has(requiredGrant)) {
          throw new Error(`Unauthorized: Missing grant '${requiredGrant}'`);
        }
        return findResidentById(id);
      },
    }),

Validation of the output

{
  "data": {
    "resident": {
      "id": "1",
      "name": "John Smith",
      "attendance": [
        {
          "program": {
            "title": "Morning Exercise"
          },
          "status": "present"
        },
        {
          "program": {
            "title": "Art Therapy"
          },
          "status": "absent"
        }
      ]
    },
    "second": {
      "id": "2",
      "name": "Mary Johnson"
    }
  }
}

Validated

against the schema

Send to client

GraphQL Pros

  • Works with any client that can POST
  • Fetch what you need
  • Schema contract (multi Schema!!)
  • LLMs know how to query
  • Observability is great
  • Amazing versioning patterns
  • Resource based Authorization checks

GraphQL Cons

  • Server-side waterfall
  • Difficult to implement at first
  • Runtime Cost of validation
  • Tree response can grow quite fast
  • Somewhat rigid schema definition

Model Context Protocol

We're June 19th 2025
you are cool if you use MCP

First there was the schema

const databaseRead = tool({
  name: "database_query_readonly",
  description:
    "Readonly database query for MySQL, use this if there are no direct tools",
  args: z.object({ query: z.string() }),
  async run(input) {
    return db.transaction(async (tx) => tx.execute(input.query), {
      accessMode: "read only",
      isolationLevel: "read committed",
    })
  },
})

const databaseWrite = tool({
  name: "database_query_write",
  description:
    "DANGEROUS operation that writes to the database. You MUST triple check with the user before using this tool - show them the query you are about to run.",
  args: z.object({ query: z.string() }),
  async run(input) {
    return db.transaction(async (tx) => tx.execute(input.query), {
      isolationLevel: "read committed",
    })
  },
})

Then you register the servers

Embrace the tool loop

Embrace the tool loop

MCP pros

  • MCP servers can do anything you want (play piano, control your lights, save to DBs, crawl the web, call other LLMs)
  • Quite good* at just making it work
  • Quite good* at interpreting results and moving to next steps
  • somewhat universal** text API

MCP cons

  • LLMs are just slow
  • Security story is not great
  • Auth story is not great
  • Observability is not great
  • Guard rails are difficult to enforce
  • LLMs are very un-evenly trained on tool calling

Why not both?

Why not both?

const getGraphQLSchema = tool({
  name: "get_graphql_schema",
  description: "Get the generated GraphQL schema for the application",
  args: z.object({}),
  async run() {
      const schemaPath = join(process.cwd(), "generated-schema.graphql")
      const schema = readFileSync(schemaPath, "utf-8")
      return {
        schema,
        message: "GraphQL schema loaded successfully"
      }
  },
})

const graphqlQuery = tool({
  name: "graphql_query",
  description: "Execute a GraphQL query against the API. Use this to fetch data from the GraphQL endpoint.",
  args: z.object({ 
    query: z.string().describe("The GraphQL query to execute"),
    variables: z.record(z.any()).optional().describe("Optional variables for the GraphQL query")
  }),
  async run(input) {
    const { query, variables } = input
      const response = await fetch(process.env.GRAPHQL_ENDPOINT, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "Authorization": "Bearer " + token
        },
        body: JSON.stringify({ query, variables }),
      })
      return {
        success: true,
        response,
        note: "This is a demo response. In production, this would make an actual HTTP request to your GraphQL endpoint."
      }
  },
})

Pros

  • Users can now define their own workflows
  • All the auth/audit/logic is centralized
  • New features magically appear (csv upload for instance)
  • Glue to other MCP server ecosystem

Cons

  • Typing in a chat or via voice is not a good user experience
  • Everything is slow (runtime)
  • The LLMs can get lost easily and start looping
  • Registering a new server / model may destroy a flow that was working fineĀ 

Malleable Software

Malleability refers to the ability of a material to be deformed under pressure without breaking

Malleable Software

The problem is clients are applying pressure in different, often conflicting, directions

WordPress

WordPress (self Hosted)

White labeling

Technical skills

Malleability

linear

website builders

Spreadsheets

maintenance burden

Malleability

Feature flags all the things

We know better than you

From the user perspective

The current landscape to choose a tool is like a food court

Lots of options, yet none that is just right

From the user perspective

What if it was more like a kitchen?

With tools and ingredients ready to be used

From the builder perspective

We've been trained to say no to change requests.

A positive change for someone is a breaking one to another

From the builder perspective

What if we could actually author our perfect vision, the authoritative best flow.
While not alienating users that are perfectly adhering to it.

Following the perfect vision,

highest velocity

Detours to get the users

and the money

to keep going

Following the perfect vision,

highest velocity

Can we have a CSV import tool?

split this view in two

Following the perfect vision,

highest velocity

Can we have a CSV export tool?

split this view in two

Marketing teams

Operations

In practice

Product scope

Do it all

Product scope

My vision

Product scope

Micro-frontends

Ops

Marketing

Sales

Resident

Families

Admin

Product scope

pico-frontends?

Ops

Marketing

Sales

Resident

Families

Admin

Product scope

nano-frontends?

Ops

Marketing

Sales

Resident

Families

Admin

Requirements

  • Strong Authentification layer (IDP)
  • Strong data layer with authorization
  • Strong design system for code generation
  • Strong Observability & guard rails
  • Strong static type-checking
  • Strong risk-tolerance

Thank you

Made with Slides.com