Building Metis Part 3: Strava Sync with OCI Functions

Building Metis Part 3: Strava Sync with OCI Functions

In part 1 I introduced Metis and the architecture, and in part 2 I went deep on the ADB 26ai MCP endpoint. In this post I want to walk through how I am loading my Strava activity history into ADB on a schedule, using OCI Functions.

For those who are new to OCI Functions, it is Oracle’s serverless platform, built on the open-source Fn Project. You write a function in Python, Node, Java, or Go, package it as a Docker image, and OCI runs it on demand. You only pay for invocation time. For a personal use case like syncing my Strava data once a day, it is basically free.

What the Function Does

The Strava sync function does five things, in order:

  1. Refreshes my Strava OAuth access token using a stored refresh token
  2. Calls Strava’s /athlete/activities endpoint, paginating through results
  3. For each activity, calls /activities/{id} to get the detailed metrics including calories (the list endpoint doesn’t return calories — gotcha alert)
  4. Initializes an MCP session against ADB 26ai (handshake once, reuse for all upserts)
  5. Calls the UPSERT_ACTIVITY MCP tool for each activity to insert or update it in the STRAVA_ACTIVITIES table

By the time it is done, I have my full Strava history sitting in ADB, ready to be queried by the AI co-pilot.

The Function Code

Here is the structure of func.py:

import io
import json
import logging
import os
import requests
from fdk import response

STRAVA_CLIENT_ID     = os.environ.get("STRAVA_CLIENT_ID", "")
STRAVA_CLIENT_SECRET = os.environ.get("STRAVA_CLIENT_SECRET", "")
STRAVA_REFRESH_TOKEN = os.environ.get("STRAVA_REFRESH_TOKEN", "")
ADB_USERNAME         = os.environ.get("ADB_USERNAME", "strava")
ADB_PASSWORD         = os.environ.get("ADB_PASSWORD", "")
ADB_OCID             = os.environ.get("ADB_OCID", "")
ADB_REGION           = os.environ.get("ADB_REGION", "ca-toronto-1")

def refresh_strava_token():
    r = requests.post("https://www.strava.com/oauth/token", data={
        "client_id": STRAVA_CLIENT_ID,
        "client_secret": STRAVA_CLIENT_SECRET,
        "refresh_token": STRAVA_REFRESH_TOKEN,
        "grant_type": "refresh_token",
    })
    r.raise_for_status()
    return r.json()["access_token"]

def fetch_strava_activities(access_token, after_ts, page=1):
    r = requests.get("https://www.strava.com/api/v3/athlete/activities",
        headers={"Authorization": f"Bearer {access_token}"},
        params={"after": after_ts, "per_page": 50, "page": page})
    r.raise_for_status()
    return r.json()

def fetch_strava_activity_detail(access_token, activity_id):
    """The list endpoint doesn't return calories. This one does."""
    r = requests.get(f"https://www.strava.com/api/v3/activities/{activity_id}",
        headers={"Authorization": f"Bearer {access_token}"})
    r.raise_for_status()
    return r.json()

def handler(ctx, data: io.BytesIO = None):
    try:
        body = json.loads(data.getvalue()) if data and data.getvalue() else {}
        days_back = body.get("days_back", 7)
        after_ts = int(time.time()) - (days_back * 86400)

        access_token = refresh_strava_token()

        # 1. Paginate through summary list
        all_acts, page = [], 1
        while True:
            acts = fetch_strava_activities(access_token, after_ts, page)
            if not acts: break
            all_acts.extend(acts)
            if len(acts) < 50: break
            page += 1

        # 2. Fetch detail for each (needed for calories)
        detailed = []
        for act in all_acts:
            try:
                detailed.append(fetch_strava_activity_detail(access_token, act["id"]))
            except Exception as e:
                logging.warning(f"Detail fetch failed for {act['id']}: {e}")
                detailed.append(act)

        # 3. Upsert into ADB via MCP
        adb_token = get_adb_token()
        mcp_initialize(adb_token)
        synced = 0
        for act in detailed:
            upsert_activity(adb_token, act)
            synced += 1

        return response.Response(ctx, response_data=json.dumps({
            "status": "ok", "synced": synced, "total": len(detailed),
        }), headers={"Content-Type": "application/json"})

    except Exception as e:
        logging.error(f"Sync failed: {e}")
        return response.Response(ctx, response_data=json.dumps({
            "status": "error", "message": str(e),
        }), status_code=500)

The full file is a bit longer (the get_adb_token, mcp_initialize, and upsert_activity helpers handle the JSON-RPC over HTTP plumbing I covered in part 2), but this is the shape.

The FUNC.YAML

schema_version: 20180708
name: strava-sync
version: 0.0.14
runtime: python
build_image: fnproject/python:3.11-dev
run_image: fnproject/python:3.11
entrypoint: /python/bin/fdk /function/func.py handler
memory: 512
timeout: 300

512 MB memory is more than enough,  most of the time is waiting on Strava’s API. Timeout of 300 seconds gives me headroom for a full backfill (962 activities took about 4 minutes the first time).

Creating the Function App with the Right Subnet

This is where I got bitten the first time. OCI Functions runs in a subnet you specify when you create the application. The subnet has to exist before the app is created, and once it is created you cannot change it via the OCI Console, you have to delete and recreate the app.

I created the app with fn CLI:

fn create app metis-strava-sync \
  --annotation oracle.com/oci/subnetIds='["ocid1.subnet.oc1.ca-toronto-1.aaaaaa..."]'

If you skip the annotation, the CLI will complain. Make sure the subnet has internet egress (via NAT gateway or Internet Gateway), or the function won’t be able to reach Strava’s API.

Setting Config Variables

The function reads everything from environment variables, which OCI Functions calls “config” at the application level. I set them with:

fn config app metis-strava-sync STRAVA_CLIENT_ID 236109
fn config app metis-strava-sync STRAVA_CLIENT_SECRET <secret>
fn config app metis-strava-sync STRAVA_REFRESH_TOKEN <token>
fn config app metis-strava-sync ADB_USERNAME strava
fn config app metis-strava-sync ADB_PASSWORD <password>
fn config app metis-strava-sync ADB_OCID ocid1.autonomousdatabase.oc1.ca-toronto-1...

One thing to watch: shell escaping. When my ADB password had a ! in it, zsh added literal backslashes before each !, and those backslashes ended up stored in the config. Strava and Oracle both then rejected the credentials. The fix is to use printfinstead of echo -n if you are piping to base64, and to quote the value carefully:

fn config app metis-strava-sync ADB_PASSWORD 'XXXXXXXXX'

Single quotes around the value prevent zsh from doing anything with the bang.

Deploying with Docker

fn deploy does three things: builds the Docker image, pushes it to OCI Container Registry, and updates the function definition to point at the new image. The first time you do this you need to log in to OCIR:

docker login yyz.ocir.io \
  -u 'yzprungsug6a/xxxxxx@mydomain.com' \
  -p '<auth-token-from-OCI-console>'

Then:

cd ~/Documents/Claude/strava-sync
fn deploy --app metis-strava-sync

The image gets built for linux/amd64 (important on Apple Silicon, Docker needs to cross-build), pushed to OCIR, and the function is updated. Each deploy bumps the version automatically.

Invoking the Function

For testing:

echo '{"days_back": 7}' | fn invoke metis-strava-sync strava-sync

For production I have a Resource Scheduler running the function once a day at 6 AM my time. You can also wire it up to OCI Events or Notifications if you want event-driven invocation.

IAM Policies – Don’t Skip This

For the function to read secrets from Vault and write to ADB, I needed two IAM pieces:

Dynamic group matching all functions in the compartment:

ALL { resource.type = 'fnfunc', resource.compartment.id = 'ocid1.compartment.oc1..xxx' }

Policy granting that dynamic group what it needs:

Allow dynamic-group metis-functions-dg to read secret-bundles in compartment metis
Allow dynamic-group metis-functions-dg to manage fn-invocation in compartment metis
Allow dynamic-group metis-functions-dg to use autonomous-databases in compartment metis

Without read secret-bundles, the function cannot pull credentials from Vault. Without use autonomous-databases, the MCP calls fail with 401.

Closing Thoughts

OCI Functions hits a sweet spot for personal automation. I pay almost nothing (well under a dollar a month for daily syncs), I do not manage any infrastructure, and the function lives close to my ADB so the latency is minimal. If you have a recurring data pipeline that runs on a schedule and finishes in under five minutes, Functions is hard to beat.

In the final post of this series I am going to walk through how I moved every secret out of .env and into OCI Vault, including the client secret for the OIDC login flow, the ADB password, and the Gemini API key. This one removed a real footgun (secrets sitting on my laptop in plaintext).

Rene Antunez
antunez.rene@gmail.com
No Comments

Leave a Reply